From 83d4f1369513c1dc3085ffd666b95aaabb80182c Mon Sep 17 00:00:00 2001 From: Phillip Trudeau-Tavara Date: Sat, 10 Dec 2022 06:55:19 -0500 Subject: [PATCH] Initial crash dump reporting (broken!) --- .gitignore | 1 + Flight.vcxproj | 1 + Flight.vcxproj.filters | 3 + crash_handler.cpp | 573 +++++++++++++++++++++++++++++++++++++++++ gamestate.c | 2 +- main.c | 6 +- 6 files changed, 584 insertions(+), 2 deletions(-) create mode 100644 crash_handler.cpp diff --git a/.gitignore b/.gitignore index 7050b7b..33d67bd 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ flight_release.exe *.lib *.ilk *.gen.h +CrashDump_*.dmp diff --git a/Flight.vcxproj b/Flight.vcxproj index cda0cd4..9aff11a 100644 --- a/Flight.vcxproj +++ b/Flight.vcxproj @@ -186,6 +186,7 @@ + diff --git a/Flight.vcxproj.filters b/Flight.vcxproj.filters index 305979b..20d1111 100644 --- a/Flight.vcxproj.filters +++ b/Flight.vcxproj.filters @@ -153,6 +153,9 @@ Source Files + + Source Files + diff --git a/crash_handler.cpp b/crash_handler.cpp new file mode 100644 index 0000000..0bd9910 --- /dev/null +++ b/crash_handler.cpp @@ -0,0 +1,573 @@ +#include "buildsettings.h" + +#define _CRT_SECURE_NO_WARNINGS + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef defer +struct defer_dummy +{ +}; +template +struct deferrer +{ + F f; + ~deferrer() { f(); } +}; +template +deferrer operator*(defer_dummy, F f) { return {f}; } +#define DEFER_(LINE) zz_defer##LINE +#define DEFER(LINE) DEFER_(LINE) +#define defer auto DEFER(__LINE__) = defer_dummy{} *[&]() +#endif // defer + +#include +#pragma comment(lib, "Ole32.lib") +#pragma comment(lib, "Oleaut32.lib") +#pragma comment(lib, "Shell32.lib") +bool view_file_in_system_file_browser(const wchar_t *wfullpath) +{ + bool res = false; + if (wfullpath) + { + { // Good version of the functionality + const wchar_t *wpath = wfullpath; + if (!wcsncmp(wfullpath, L"\\\\?\\", 4)) + { + wpath += 4; + } + if (wpath) + { + HRESULT hr = CoInitialize(nullptr); + if (hr == S_OK || hr == S_FALSE) + { + PIDLIST_ABSOLUTE pidl; + SFGAOF flags; + if (SHParseDisplayName(wpath, nullptr, &pidl, 0, &flags) == S_OK) + { + if (SHOpenFolderAndSelectItems(pidl, 0, nullptr, 0) == S_OK) + { + res = true; + } + CoTaskMemFree(pidl); + } + CoUninitialize(); + } + } + } + if (!res) + { // Worse Fallback if good version fails + WCHAR wcmd[65536]; + wnsprintfW(wcmd, ARRAYSIZE(wcmd), L"explorer /select,\"%s\"", wfullpath); + + if (_wsystem(wcmd) >= 0) + { + res = true; + } + } + } + return res; +} + + +#define DISCORD_WEBHOOK_ENDPOINT "/api/webhooks/922366591751053342/pR59Za5xL1HcvGSrxJ7hb1UCa85zfdtZyDet15I_CZgrY9RkAq73uAQ2Obo1Zi9QBvvX" + +#define ErrBox(msg, flags) MessageBoxW(nullptr, L"" msg, L"Fatal Error", flags | MB_SYSTEMMODAL | MB_SETFOREGROUND) +#define fatal_init_error(s) ErrBox("The game had a fatal error and must close.\n\"" s "\"\nPlease report this to team@happenlance.com or the Happenlance Discord.", MB_OK | MB_ICONERROR) + +extern "C" { +// TODO: Internationalization for error messages. +#pragma comment(lib, "DbgHelp.lib") +#pragma comment(lib, "ComCtl32.lib") +#pragma comment(lib, "WinHTTP.lib") +#pragma comment(lib, "Shlwapi.lib") +void do_crash_handler() +{ + + // Get the command line + int argc = 0; + wchar_t *cmd = GetCommandLineW(); + if (!cmd || !cmd[0]) + { + return; // Error: just run the app without a crash handler. + } + wchar_t **wargv = CommandLineToArgvW(cmd, &argc); // Passing nullptr here crashes! + if (!wargv || !wargv[0]) + { + return; // Error: just run the app without a crash handler. + } + + // Parse the command line for -no-crash-handler + bool crashHandler = true; + for (int i = 0; i < argc; ++i) + { + if (!wcscmp(wargv[i], L"-no-crash-handler")) + { + crashHandler = false; + } + } + if (!crashHandler) + { // We already *are* the subprocess - continue with the main program! + return; + } + + // Concatenate -no-crash-handler onto the command line for the subprocess + int cmdLen = 0; + while (cmd[cmdLen]) + { // could use wcslen() here, but Clang ASan's wcslen() can be bugged sometimes + cmdLen++; + } + const wchar_t *append = L" -no-crash-handler"; + int appendLen = 0; + while (append[appendLen]) + { + appendLen++; + } + wchar_t *cmdNew = (wchar_t *)calloc(cmdLen + appendLen + 1, sizeof(wchar_t)); // @Leak + if (!cmdNew) + { + return; // Error: just run the app without a crash handler. + } + memcpy(cmdNew, cmd, cmdLen * sizeof(wchar_t)); + memcpy(cmdNew + cmdLen, append, appendLen * sizeof(wchar_t)); + +// Crash handler loop: run the program until it succeeds or the user chooses not to restart it +restart:; + + // Parameters for starting the subprocess + STARTUPINFOW siw = {}; + siw.cb = sizeof(siw); + siw.dwFlags = STARTF_USESTDHANDLES; + siw.hStdInput = GetStdHandle(STD_INPUT_HANDLE); // @Leak: CloseHandle() + siw.hStdOutput = GetStdHandle(STD_ERROR_HANDLE); + siw.hStdError = GetStdHandle(STD_OUTPUT_HANDLE); + PROCESS_INFORMATION pi = {}; // @Leak: CloseHandle() + + // Launch suspended, then read-modify-write the PEB (see below), then resume -p 2022-03-04 + if (!CreateProcessW(nullptr, cmdNew, nullptr, nullptr, true, + CREATE_SUSPENDED | DEBUG_ONLY_THIS_PROCESS, nullptr, nullptr, &siw, &pi)) + { + // If we couldn't create a subprocess, then just run the program without a crash handler. + // That's not great, but it's presumably better than stopping the user from running at all! + return; + } + + // NOTE: SteamAPI_Init() takes WAY longer On My Machine(tm) when a debugger is present. + // (The DLL file steam_api64.dll does indeed call IsDebuggerPresent() sometimes.) + // It's clear that Steam does extra niceness for us when debugging, but we DO NOT + // want this to destroy our load times; I measure 3.5x slowdown (0.6s -> 2.1s). + // The only way I know to trick the child process into thinking it is free of a + // debugger is to clear the BeingDebugged byte in the Process Environment Block. + // If we are unable to perform this advanced maneuver, we will gracefully step back + // and allow Steam to ruin our loading times. -p 2022-03-04 + auto persuade_process_no_debugger_is_present = [](HANDLE hProcess) + { + // Load NTDLL + HMODULE ntdll = LoadLibraryA("ntdll.dll"); + if (!ntdll) + return; + + // Get NtQueryInformationProcess function + auto NtQueryInformationProcess = + (/*__kernel_entry*/ NTSTATUS(*)(HANDLE, PROCESSINFOCLASS, PVOID, ULONG, PULONG)) + GetProcAddress(ntdll, "NtQueryInformationProcess"); + if (!NtQueryInformationProcess) + return; + + // Query process information to find the PEB address + PROCESS_BASIC_INFORMATION pbi = {}; + DWORD queryBytesRead = 0; + if (NtQueryInformationProcess(hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), &queryBytesRead) != 0 || queryBytesRead != sizeof(pbi)) + return; + + // Read the PEB of the child process + PEB peb = {}; + SIZE_T processBytesRead = NULL; + if (!ReadProcessMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(peb), &processBytesRead) || processBytesRead != sizeof(peb)) + return; + printf("Child process's peb.BeingDebugged is %d, setting to 0...\n", peb.BeingDebugged); + + // Gaslight the child into believing we are not watching + peb.BeingDebugged = 0; + + // Write back the modified PEB + SIZE_T processBytesWritten = NULL; + if (!WriteProcessMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(peb), &processBytesWritten) || processBytesWritten != sizeof(peb)) + return; + }; + persuade_process_no_debugger_is_present(pi.hProcess); + + // Helper function to destroy the subprocess + auto exit_child = [&] + { + TerminateProcess(pi.hProcess, 1); // Terminate before detaching, so you don't see Windows Error Reporting. + DebugActiveProcessStop(GetProcessId(pi.hProcess)); // Detach + WaitForSingleObject(pi.hProcess, 2000); // Wait for child to die, but not forever. + }; + + // Kick off the subprocess + if (ResumeThread(pi.hThread) != 1) + { + exit_child(); + fatal_init_error("Could not start main game thread"); + ExitProcess(1); // @Note: could potentially "return;" here instead if you wanted. + } + + // Debugger loop: catch (and ignore) all debug events until the program exits or hits a last-chance exception + char filename[65536] = {0}; + WCHAR wfilename[65536] = {0}; + HANDLE file = nullptr; + for (;;) + { + + // Get debug event + DEBUG_EVENT de = {}; + if (!WaitForDebugEvent(&de, INFINITE)) + { + exit_child(); + fatal_init_error("Waiting for debug event failed"); + ExitProcess(1); + } + + // If the process exited, nag about failure, or silently exit on success + if (de.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT && de.dwProcessId == pi.dwProcessId) + { + + // If the process exited unsuccessfully, prompt to restart it + // @Todo: in these cases, no dump can be made, so upload just the stdout log and profiling trace + if (de.u.ExitThread.dwExitCode != 0) + { + + // Terminate & detach just to be safe + exit_child(); + + // Prompt to restart + MessageBeep(MB_ICONINFORMATION); // MB_ICONQUESTION makes no sound + if (MessageBoxW(nullptr, + L"The game had a fatal error and must close.\n" + "Unfortunately, a crash report could not be generated. Sorry!\n" + "Please report this to team@happenlance.com or the Happenlance Discord.\n" + "Restart the game?\n", + L"Fatal Error", + MB_YESNO | MB_ICONQUESTION | MB_SYSTEMMODAL | MB_SETFOREGROUND) == IDYES) + { + goto restart; + } + } + + // Bubble up the failure code - this is where successful program runs will end up! + ExitProcess(de.u.ExitThread.dwExitCode); + } + + // If the process had some other debug stuff, we don't care. + if (de.dwDebugEventCode != EXCEPTION_DEBUG_EVENT) + { + ContinueDebugEvent(de.dwProcessId, de.dwThreadId, DBG_CONTINUE); + continue; + } + + // Skip first-chance exceptions or exceptions for processes we don't care about (shouldn't ever happen). + if (de.u.Exception.dwFirstChance || de.dwProcessId != GetProcessId(pi.hProcess)) + { + ContinueDebugEvent(de.dwProcessId, de.dwThreadId, DBG_EXCEPTION_NOT_HANDLED); + continue; + } + +// By here, we have hit a real, last-chance exception. This is a crash we should generate a dump for. +#define crash_report_failure(str) \ + ErrBox( \ + "The game had a fatal error and must close.\n" \ + "A crash report could not be produced:\n\"" str "\"\n" \ + "Please report this to team@happenlance.com or the Happenlance Discord.", \ + MB_OK | MB_ICONERROR); + + // Create crash dump filename + snprintf(filename, sizeof(filename), "./CrashDump_%d.dmp", (int)time(NULL)); + + // Convert filename to UTF-16 + MultiByteToWideChar(CP_UTF8, 0, filename, -1, wfilename, ARRAYSIZE(wfilename)); + + // Create crash dump file + file = CreateFileW(wfilename, GENERIC_WRITE | GENERIC_READ, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) + { + exit_child(); + crash_report_failure("The crash dump file could not be created. Sorry!"); + ExitProcess(1); + } + + // Generate exception pointers out of excepting thread context + CONTEXT c = {}; + if (HANDLE thread = OpenThread(THREAD_ALL_ACCESS, true, de.dwThreadId)) + { + c.ContextFlags = CONTEXT_ALL; + GetThreadContext(thread, &c); + CloseHandle(thread); + } + EXCEPTION_POINTERS ep = {}; + ep.ExceptionRecord = &de.u.Exception.ExceptionRecord; + ep.ContextRecord = &c; + MINIDUMP_EXCEPTION_INFORMATION mei = {}; + mei.ThreadId = de.dwThreadId; + mei.ExceptionPointers = &ep; + mei.ClientPointers = false; + + // You could add some others here, but these should be good. + int flags = MiniDumpNormal | MiniDumpWithHandleData | MiniDumpScanMemory | MiniDumpWithUnloadedModules | MiniDumpWithProcessThreadData | MiniDumpWithThreadInfo | MiniDumpIgnoreInaccessibleMemory; + + // Write minidump + if (!MiniDumpWriteDump(pi.hProcess, GetProcessId(pi.hProcess), file, + (MINIDUMP_TYPE)flags, &mei, nullptr, nullptr)) + { + exit_child(); + crash_report_failure("The crash dump could not be written. Sorry!"); + ExitProcess(1); + } + + // @Todo: ZIP compress the crash dump files here, with graceful fallback to uncompressed dumps. + + // Cleanup: Destroy subprocess now that we have a dump. + // Note that we want to do this before doing any blocking interface dialogs, + // because otherwise you would leave an arbitrarily broken program lying around + // longer than you need to. + exit_child(); + break; + } + + // Prompt to upload crash dump + int res = 0; + bool uploaded = false; + if (!(res = ErrBox( + "The game had a fatal error and must close.\n" + "Send anonymous crash report?\n" + "This will go directly to the developers on Discord,\n" + "and help fix the problem.", + MB_YESNO | MB_ICONERROR))) + ExitProcess(1); + + // Upload crash dump + if (res == IDYES) + { + + // Setup window class for progress window + WNDCLASSEXW wcex = {sizeof(wcex)}; + wcex.style = CS_HREDRAW | CS_VREDRAW; + wcex.lpszClassName = L"bar"; + wcex.hbrBackground = (HBRUSH)(COLOR_WINDOW); + wcex.hCursor = LoadCursor(GetModuleHandleA(nullptr), IDC_ARROW); + wcex.lpfnWndProc = [](HWND h, UINT m, WPARAM w, LPARAM l) -> LRESULT + { + return m == WM_QUIT || m == WM_CLOSE || m == WM_DESTROY ? 0 : DefWindowProcW(h, m, w, l); + }; + wcex.hInstance = GetModuleHandleA(nullptr); + if (!RegisterClassExW(&wcex)) + { + ExitProcess(1); + } + HWND hWnd = nullptr; + HWND ctrl = nullptr; + + // Initialize common controls for progress bar + INITCOMMONCONTROLSEX iccex = {sizeof(iccex)}; + iccex.dwICC = ICC_PROGRESS_CLASS; + if (InitCommonControlsEx(&iccex)) + { + + // Create progress window and progress bar child-window + hWnd = CreateWindowExW(0, wcex.lpszClassName, L"Uploading...", + WS_SYSMENU | WS_CAPTION | WS_VISIBLE, CW_USEDEFAULT, SW_SHOW, + 320, 80, nullptr, nullptr, GetModuleHandleA(nullptr), nullptr); + ctrl = CreateWindowExW(0, PROGRESS_CLASSW, L"", + WS_CHILD | WS_VISIBLE | PBS_SMOOTH, 10, 10, + 280, 20, hWnd, (HMENU)12345, GetModuleHandleA(nullptr), nullptr); + } + else + { + ExitProcess(1); + } + + // Infinite loop: Attempt to upload the crash dump until the user cancels or it succeeds + do + { + + // Position the progress window to the centre of the screen + RECT r; + GetWindowRect(hWnd, &r); + int ww = r.right - r.left, wh = r.bottom - r.top; + int sw = GetSystemMetrics(SM_CXSCREEN), sh = GetSystemMetrics(SM_CYSCREEN); + SetWindowPos(hWnd, HWND_TOP, (sw - ww) / 2, (sh - wh) / 2, 0, 0, SWP_NOSIZE); + + // Helper function to set the loading bar to a certain position. + auto update_loading_bar = [&](float amt) + { + if (hWnd && ctrl) + { + SendMessageW(ctrl, PBM_SETPOS, (WPARAM)(amt * 100), 0); + ShowWindow(hWnd, SW_SHOW); + UpdateWindow(hWnd); + MSG msg = {}; + while (PeekMessageW(&msg, nullptr, 0, 0, 1) > 0) + { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + }; + + auto try_upload = [&]() -> bool + { + float x = 0; + update_loading_bar(x); + + // Build MIME multipart-form payload + static char body[1 << 23]; // ouch that's a big static buffer!!! + const char *bodyPrefix = + "--19024605111143684786787635207\r\n" + "Content-Disposition: form-data; name=\"payload_json\"\r\n\r\n{\"content\":\"" + "Astris v" +#define STRINGIZE_(N) #N +#define STRINGIZE(N) STRINGIZE_(N) + STRINGIZE(GIT_RELEASE_TAG) " Crash Report" + "\"}\r\n--19024605111143684786787635207\r\n" + "Content-Disposition: form-data; name=\"files[0]\"; filename=\""; + const char *bodyInfix = "\"\r\n" + "Content-Type: application/octet-stream\r\n" + "\r\n"; + const char *bodyPostfix = "\r\n--19024605111143684786787635207--\r\n"; + + // Printf the prefix, filename, infix + int headerLen = snprintf(body, sizeof(body), "%s%s%s", bodyPrefix, filename, bodyInfix); + if (headerLen != strlen(bodyPrefix) + strlen(filename) + strlen(bodyInfix)) + return false; + update_loading_bar(x += 0.1f); + + // Get crash dump file size + LARGE_INTEGER fileSizeInt = {}; + GetFileSizeEx(file, &fileSizeInt); + uint64_t fileSize = fileSizeInt.QuadPart; + if (fileSize >= 8000000) + return false; // discord limit + int bodyLen = headerLen + (int)fileSize + (int)(sizeof(bodyPostfix)-1); + if (bodyLen >= sizeof(body)) + return false; // buffer overflow + update_loading_bar(x += 0.1f); + + // Seek file to start + if (SetFilePointer(file, 0, nullptr, FILE_BEGIN) != 0) + return false; + + // Copy entire file into the space after the body infix + DWORD bytesRead = 0; + if (!ReadFile(file, body + headerLen, (DWORD)fileSize, &bytesRead, nullptr)) + return false; + if (bytesRead != fileSize) + return false; + update_loading_bar(x += 0.1f); + + // Print the body postfix after the data file (overflow already checked) + strncpy(body + headerLen + fileSize, bodyPostfix, sizeof(body) - headerLen - fileSize); + update_loading_bar(x += 0.1f); + + // Windows HTTPS stuff from here on out... + HINTERNET hSession = WinHttpOpen(L"Discord Crashdump Webhook", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, 0); + if (!hSession) + return false; + defer { WinHttpCloseHandle(hSession); }; + update_loading_bar(x += 0.1f); + + // Connect to domain + HINTERNET hConnect = WinHttpConnect(hSession, L"discord.com", INTERNET_DEFAULT_HTTPS_PORT, 0); + if (!hConnect) + return false; + defer { WinHttpCloseHandle(hConnect); }; + update_loading_bar(x += 0.1f); + + // Begin POST request to the discord webhook endpoint + HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"POST", + L"" DISCORD_WEBHOOK_ENDPOINT, + nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE); + if (!hRequest) + return false; + defer { WinHttpCloseHandle(hRequest); }; + update_loading_bar(x += 0.1f); + + // Send request once - don't handle auth challenge, credentials, reauth, redirects + const wchar_t ContentType[] = L"Content-Type: multipart/form-data; boundary=19024605111143684786787635207"; + if (!WinHttpSendRequest(hRequest, ContentType, ARRAYSIZE(ContentType), + body, bodyLen, bodyLen, 0)) + return false; + update_loading_bar(x += 0.1f); + + // Wait for response + if (!WinHttpReceiveResponse(hRequest, nullptr)) + return false; + update_loading_bar(x += 0.1f); + + // Pull headers from response + DWORD dwStatusCode, dwSize = sizeof(dwStatusCode); + if (!WinHttpQueryHeaders(hRequest, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + nullptr, &dwStatusCode, &dwSize, nullptr)) + return false; + if (dwStatusCode != 200) + return false; + update_loading_bar(x += 0.1f); + + return true; + }; + res = 0; + uploaded = try_upload(); + if (!uploaded) + { + if (!(res = MessageBoxW(hWnd, L"Sending failed. Retry?", L"Fatal Error", MB_RETRYCANCEL | MB_ICONWARNING | MB_SYSTEMMODAL))) + ExitProcess(1); + } + } while (res == IDRETRY); + + // Cleanup + if (hWnd) + { + DestroyWindow(hWnd); + } + UnregisterClassW(wcex.lpszClassName, GetModuleHandleA(nullptr)); + } + + // Cleanup + CloseHandle(file); + + // Prompt to restart + MessageBeep(MB_ICONINFORMATION); // MB_ICONQUESTION makes no sound + if (MessageBoxW(nullptr, uploaded ? L"Thank you for sending the crash report.\n" + "You can send more info to the #bugs channel\n" + "in the Astris Discord.\n" + "Restart the game?\n" + : view_file_in_system_file_browser(wfilename) ? L"The crash report folder has been opened.\n" + "You can send the report to the #bugs channel\n" + "in the Astris Discord.\n" + "Restart the game?\n" + : L"The crash report can be found in the program installation directory.\n" + "You can send the report to the #bugs channel\n" + "in the Astris Discord.\n" + "Restart the game?\n", + L"Fatal Error", + MB_YESNO | MB_ICONQUESTION | MB_SYSTEMMODAL | MB_SETFOREGROUND) == IDYES) + { + goto restart; + } + + // Return 1 because the game crashed, not because the crash report failed + ExitProcess(1); +} +} diff --git a/gamestate.c b/gamestate.c index 04b512e..fcf7799 100644 --- a/gamestate.c +++ b/gamestate.c @@ -82,10 +82,10 @@ void quit_with_popup(const char *message_utf8, const char *title_utf8) free(message_wchar); free(title_wchar); - PostQuitMessage(1); (void)message_out_len; (void)title_out_len; #endif + exit(0); } void __flight_assert(bool cond, const char *file, int line, const char *cond_string) diff --git a/main.c b/main.c index 26f6fe8..53ec814 100644 --- a/main.c +++ b/main.c @@ -2312,8 +2312,12 @@ void event(const sapp_event *e) } } +extern void do_crash_handler(); + sapp_desc sokol_main(int argc, char *argv[]) { + // do_crash_handler(); + bool hosting = false; stm_setup(); ma_mutex_init(&server_info.info_mutex); @@ -2340,4 +2344,4 @@ sapp_desc sokol_main(int argc, char *argv[]) .win32_console_attach = true, .sample_count = 4, // anti aliasing }; -} \ No newline at end of file +}