Capture crash dumps for tests in CI

This commit is contained in:
Duncan Ogilvie 2026-07-19 15:08:06 +02:00
parent 385e1ce1d0
commit d8ea621435
5 changed files with 49 additions and 12 deletions

View file

@ -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()

View file

@ -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();

View file

@ -23,9 +23,10 @@ static int curScriptId = 0;
static bool dbgStopped = false;
static DWORD dwGuiThreadId = 0;
static moodycamel::BlockingConcurrentQueue<std::function<bool()>> queue;
static constexpr DWORD NoShutdownCtrlType = MAXDWORD;
static std::atomic<bool> shutdownRequested{ false };
static std::atomic<bool> consoleCloseRequested{ false };
static std::atomic<DWORD> shutdownCtrlType{ 0 };
static std::atomic<DWORD> shutdownCtrlType{ NoShutdownCtrlType };
static std::atomic<HANDLE> 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

View file

@ -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:

View file

@ -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)