diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 84e0c36b7..3c4672b7a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,7 +88,15 @@ jobs: - name: Run Tests shell: pwsh run: | - py src/tests/run.py --arch ${{ matrix.arch }} --engine ${{ matrix.engine }} --artifacts-dir "test-artifacts/${{ matrix.arch }}-${{ matrix.engine }}" + $artifactDir = Join-Path $PWD "test-artifacts/${{ matrix.arch }}-${{ matrix.engine }}" + $dumpDir = Join-Path $artifactDir "crash-dumps" + $localDumps = "HKCU:\Software\Microsoft\Windows\Windows Error Reporting\LocalDumps\headless.exe" + New-Item -Path $localDumps -Force | Out-Null + New-ItemProperty -Path $localDumps -Name DumpFolder -Value $dumpDir -PropertyType ExpandString -Force | Out-Null + New-ItemProperty -Path $localDumps -Name DumpType -Value 2 -PropertyType DWord -Force | Out-Null + New-ItemProperty -Path $localDumps -Name DumpCount -Value 4 -PropertyType DWord -Force | Out-Null + Write-Host "Full headless crash dumps will be written to $dumpDir" + py src/tests/run.py --arch ${{ matrix.arch }} --engine ${{ matrix.engine }} --artifacts-dir $artifactDir - name: Upload Test Artifacts if: failure() diff --git a/src/dbg/testing.cpp b/src/dbg/testing.cpp index 4ca20587e..ff6b648cd 100644 --- a/src/dbg/testing.cpp +++ b/src/dbg/testing.cpp @@ -44,16 +44,25 @@ static void setFirstFailureMessage(const String & message) gTestState.firstFailureMessage = message; } +static void logTestMessage(const String & message) +{ + // Test protocol messages must bypass the asynchronous debugger logger. + // testfinalize closes headless immediately, so a queued FINAL line can be + // discarded during process teardown before the test runner observes it. + // Start a fresh line in case another thread emitted a partial log message. + GuiAddLogMessage(("\n" + message).c_str()); +} + static void logAssertionFailure(const char* source, const char* expression, const char* message) { if(expression && *expression && message && *message) - dprintf_untranslated("[x64dbg-test] ASSERT FAIL source=%s expr=\"%s\" message=\"%s\"\n", source, expression, message); + logTestMessage(StringUtils::sprintf("[x64dbg-test] ASSERT FAIL source=%s expr=\"%s\" message=\"%s\"\n", source, expression, message)); else if(expression && *expression) - dprintf_untranslated("[x64dbg-test] ASSERT FAIL source=%s expr=\"%s\"\n", source, expression); + logTestMessage(StringUtils::sprintf("[x64dbg-test] ASSERT FAIL source=%s expr=\"%s\"\n", source, expression)); else if(message && *message) - dprintf_untranslated("[x64dbg-test] ASSERT FAIL source=%s message=\"%s\"\n", source, message); + logTestMessage(StringUtils::sprintf("[x64dbg-test] ASSERT FAIL source=%s message=\"%s\"\n", source, message)); else - dprintf_untranslated("[x64dbg-test] ASSERT FAIL source=%s\n", source); + logTestMessage(StringUtils::sprintf("[x64dbg-test] ASSERT FAIL source=%s\n", source)); } static bool assertCommon(bool condition, const char* source, const char* expression, const char* message) @@ -169,9 +178,10 @@ bool cbInstrTestFinalize(int argc, char* argv[]) reason = "script_failed"; if(reason) - dprintf_untranslated("[x64dbg-test] FINAL status=fail asserts=%llu reason=%s\n", asserts, reason); + logTestMessage(StringUtils::sprintf("[x64dbg-test] FINAL status=fail asserts=%llu reason=%s\n", asserts, reason)); else - dprintf_untranslated("[x64dbg-test] FINAL status=pass asserts=%llu\n", asserts); + logTestMessage(StringUtils::sprintf("[x64dbg-test] FINAL status=pass asserts=%llu\n", asserts)); + GuiFlushLog(); if(BridgeIsHeadless()) GuiCloseApplication(); diff --git a/src/headless/headless.cpp b/src/headless/headless.cpp index fcac57cdc..8cea8d658 100644 --- a/src/headless/headless.cpp +++ b/src/headless/headless.cpp @@ -23,9 +23,10 @@ static int curScriptId = 0; static bool dbgStopped = false; static DWORD dwGuiThreadId = 0; static moodycamel::BlockingConcurrentQueue> queue; +static constexpr DWORD NoShutdownCtrlType = MAXDWORD; static std::atomic shutdownRequested{ false }; static std::atomic consoleCloseRequested{ false }; -static std::atomic shutdownCtrlType{ 0 }; +static std::atomic shutdownCtrlType{ NoShutdownCtrlType }; static std::atomic commandThreadHandle{ nullptr }; static std::mutex redirectLogMutex; static FILE* redirectLogFile = nullptr; @@ -141,7 +142,7 @@ extern "C" __declspec(dllexport) int _gui_guiinit(int argc, char* argv[]) shutdownRequested = false; consoleCloseRequested = false; - shutdownCtrlType = 0; + shutdownCtrlType = NoShutdownCtrlType; commandThreadHandle = nullptr; // Init debugger @@ -492,6 +493,11 @@ extern "C" __declspec(dllexport) const char* _gui_translate_text(const char* sou int main(int argc, char* argv[]) { + // GitHub Actions and other Node-based parents can set + // SEM_NOGPFAULTERRORBOX. Clear it so WER LocalDumps can capture an + // unhandled headless crash without showing legacy hard-error dialogs. + SetErrorMode(SEM_FAILCRITICALERRORS); + dwGuiThreadId = GetCurrentThreadId(); // Construct user directory from executable name diff --git a/src/tests/cmdline_attach/driver.py b/src/tests/cmdline_attach/driver.py index 03e66cd7e..6649774aa 100644 --- a/src/tests/cmdline_attach/driver.py +++ b/src/tests/cmdline_attach/driver.py @@ -42,6 +42,12 @@ def append_log(log_path: Path, text: str) -> None: log_file.write("\n") +def process_exit_reason(prefix: str, returncode: int) -> str: + if os.name == "nt" and (returncode < 0 or returncode > 0xFF): + return f"{prefix}_0x{returncode & 0xFFFFFFFF:08X}" + return f"{prefix}_{returncode}" + + def fail(log_path: Path, reason: str, message: str) -> int: append_log(log_path, f'[x64dbg-test] ASSERT FAIL source=driver message="{message}"') append_log(log_path, f"[x64dbg-test] FINAL status=fail asserts=1 reason={reason}") @@ -157,7 +163,8 @@ def main() -> int: (artifacts_dir / "headless.stdout.txt").write_text(completed.stdout, encoding="utf-8", errors="replace") if completed.returncode != 0: - return fail(log_path, f"headless_exit_{completed.returncode}", f"headless exited with {completed.returncode}") + reason = process_exit_reason("headless_exit", completed.returncode) + return fail(log_path, reason, f"headless exited with {completed.returncode} ({reason})") debug_log = log_path.read_text(encoding="utf-8", errors="replace") if log_path.is_file() else "" if INIT_SCRIPT_MARKER in completed.stdout or INIT_SCRIPT_MARKER in debug_log: diff --git a/src/tests/run.py b/src/tests/run.py index 4ad667217..08f7ab64a 100644 --- a/src/tests/run.py +++ b/src/tests/run.py @@ -93,6 +93,12 @@ def timeout_output(output: str | bytes | None) -> str: return output or "" +def process_exit_reason(prefix: str, returncode: int) -> str: + if os.name == "nt" and (returncode < 0 or returncode > 0xFF): + return f"{prefix}_0x{returncode & 0xFFFFFFFF:08X}" + return f"{prefix}_{returncode}" + + def parse_test_variant(script_name: str) -> str | None: if not script_name.startswith("test") or not script_name.endswith(".txt"): return None @@ -318,7 +324,7 @@ def run_driver_test(headless: Path, test: TestCase, timeout: int, artifact_dir: if completed.returncode != 0: passed = False if reason in {"pass", "missing_final"}: - reason = f"driver_exit_{completed.returncode}" + reason = process_exit_reason("driver_exit", completed.returncode) if passed and test.fallback_check is not None: passed, reason = run_fallback_check(test.fallback_check, log_path, userdir, test.runtime_dir, artifact_dir) @@ -382,7 +388,7 @@ def run_test(headless: Path, test: TestCase, timeout: int, artifact_root: Path, if completed.returncode != 0: passed = False if reason == "pass": - reason = f"process_exit_{completed.returncode}" + reason = process_exit_reason("process_exit", completed.returncode) if passed and test.fallback_check is not None: passed, reason = run_fallback_check(test.fallback_check, log_path, userdir, test.runtime_dir, artifact_dir)