Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions Build/scripts/Invoke-CppTest.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ Initialize-VsDevEnvironment
# Suppress assertion dialog boxes (DebugProcs.dll checks this env var)
# This prevents tests from blocking on MessageBox popups
$env:AssertUiEnabled = 'false'
# Unconditional test-mode override: bypasses registry AssertMessageBox key in DebugProcs.dll
$env:FW_TEST_MODE = '1'

# Suppress Windows Error Reporting and crash dialogs
# SEM_FAILCRITICALERRORS = 0x0001
Expand Down Expand Up @@ -549,6 +551,13 @@ function Invoke-Run {
}

$process.WaitForExit()
$nativeExitCode = $null
try {
$nativeExitCode = $process.ExitCode
}
catch {
$nativeExitCode = $null
}

$logTail = @()
if (Test-Path $LogPath) {
Expand All @@ -562,15 +571,34 @@ function Invoke-Run {
Write-Host "--- end output ---" -ForegroundColor Yellow
}

# Determine exit code: parse the Unit++ summary line from the log as the authoritative
# source. Start-Process -RedirectStandardOutput in PowerShell 5.1 can return a null
# ExitCode even after WaitForExit(), so the process exit code is not reliable here.
# Determine exit code using both the real process exit code and the Unit++ summary.
# The process exit code is authoritative for crashes/teardown failures that occur after
# the Unit++ summary has already been written.
$exitCode = -1
$summaryExitCode = $null
if (-not $timedOut) {
$summaryLine = $logTail | Where-Object { $_ -match 'Tests \[Ok-Fail-Error\]: \[\d+-\d+-\d+\]' } | Select-Object -Last 1
if ($summaryLine) {
$m = [regex]::Match($summaryLine, 'Tests \[Ok-Fail-Error\]: \[(\d+)-(\d+)-(\d+)\]')
$exitCode = [int]$m.Groups[2].Value + [int]$m.Groups[3].Value
$summaryExitCode = [int]$m.Groups[2].Value + [int]$m.Groups[3].Value
}

if ($terminatedAfterCompletion) {
if ($null -ne $nativeExitCode -and $nativeExitCode -ne 0) {
$exitCode = $nativeExitCode
}
else {
$exitCode = 1
}
}
elseif ($null -ne $nativeExitCode -and $nativeExitCode -ne 0) {
$exitCode = $nativeExitCode
}
elseif ($null -ne $summaryExitCode) {
$exitCode = $summaryExitCode
}
elseif ($null -ne $nativeExitCode) {
$exitCode = $nativeExitCode
}
}

Expand Down
118 changes: 112 additions & 6 deletions Lib/src/unit++/main.cc
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,86 @@
// Terms of use are in the file COPYING
#include "main.h"
#include <algorithm>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#if defined(WIN32) || defined(WIN64)
#define WINDOWS_LEAN_AND_MEAN
#include <Windows.h>
#include <crtdbg.h>
#endif
using namespace std;
using namespace unitpp;

#if defined(WIN32) || defined(WIN64)
namespace
{
void TerminateOnSigAbrt(int)
{
_exit(3);
}

typedef HRESULT (WINAPI * PfnWerGetFlags)(HANDLE, PDWORD);
typedef HRESULT (WINAPI * PfnWerSetFlags)(DWORD);

const DWORD kWerFaultReportingNoUi = 0x00000004;
const DWORD kWerFaultReportingAlwaysShowUi = 0x00000010;

void ConfigureWindowsErrorReportingUi()
{
DWORD errorMode = GetErrorMode();
errorMode |= SEM_FAILCRITICALERRORS;
errorMode |= SEM_NOGPFAULTERRORBOX;
errorMode |= SEM_NOOPENFILEERRORBOX;
SetErrorMode(errorMode);

HMODULE hWer = LoadLibraryA("wer.dll");
if (!hWer)
return;

PfnWerGetFlags pfnWerGetFlags = reinterpret_cast<PfnWerGetFlags>(
GetProcAddress(hWer, "WerGetFlags")
);
PfnWerSetFlags pfnWerSetFlags = reinterpret_cast<PfnWerSetFlags>(
GetProcAddress(hWer, "WerSetFlags")
);

if (pfnWerSetFlags)
{
DWORD flags = 0;
if (pfnWerGetFlags)
pfnWerGetFlags(GetCurrentProcess(), &flags);

flags |= kWerFaultReportingNoUi;
flags &= ~kWerFaultReportingAlwaysShowUi;
pfnWerSetFlags(flags);
}

FreeLibrary(hWer);
}

void ConfigureCrtReportUi()
{
_set_error_mode(_OUT_TO_STDERR);

_CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE);
_CrtSetReportFile(_CRT_WARN, _CRTDBG_FILE_STDERR);
_CrtSetReportMode(_CRT_ERROR, _CRTDBG_MODE_FILE);
_CrtSetReportFile(_CRT_ERROR, _CRTDBG_FILE_STDERR);
_CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_FILE);
_CrtSetReportFile(_CRT_ASSERT, _CRTDBG_FILE_STDERR);

_set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT);
}

void SuppressInteractiveCrashUi()
{
ConfigureWindowsErrorReportingUi();
ConfigureCrtReportUi();
}
}
#endif

bool unitpp::verbose = false;
int unitpp::verbose_lvl = 0;
bool unitpp::line_fmt = false;
Expand All @@ -25,6 +101,9 @@ void unitpp::set_tester(test_runner* tr)

int main(int argc, const char* argv[])
{
#if defined(WIN32) || defined(WIN64)
SuppressInteractiveCrashUi();
#endif
printf("DEBUG: unit++ main start\n"); fflush(stdout);
options().add("v", new options_utils::opt_flag(verbose));
options().alias("verbose", "v");
Expand All @@ -42,14 +121,41 @@ int main(int argc, const char* argv[])
if (!runner)
runner = &plain;

printf("DEBUG: Calling GlobalSetup\n"); fflush(stdout);
GlobalSetup(verbose);
printf("DEBUG: Returned from GlobalSetup\n"); fflush(stdout);
int retval = 0;

try {
printf("DEBUG: Calling GlobalSetup\n"); fflush(stdout);
GlobalSetup(verbose);
printf("DEBUG: Returned from GlobalSetup\n"); fflush(stdout);
}
catch (const std::exception& e) {
fprintf(stderr, "GlobalSetup threw std::exception: %s\n", e.what());
fflush(stderr);
return 1;
}
catch (...) {
fprintf(stderr, "GlobalSetup threw an unknown exception\n");
fflush(stderr);
return 1;
}

int retval = runner->run_tests(argc, argv) ? 0 : 1;
retval = runner->run_tests(argc, argv) ? 0 : 1;
signal(SIGABRT, TerminateOnSigAbrt);

printf("DEBUG: Calling GlobalTeardown\n"); fflush(stdout);
GlobalTeardown();
try {
printf("DEBUG: Calling GlobalTeardown\n"); fflush(stdout);
GlobalTeardown();
}
catch (const std::exception& e) {
fprintf(stderr, "GlobalTeardown threw std::exception: %s\n", e.what());
fflush(stderr);
retval = 1;
}
catch (...) {
fprintf(stderr, "GlobalTeardown threw an unknown exception\n");
fflush(stderr);
retval = 1;
}
printf("DEBUG: unit++ main end (retval=%d)\n", retval); fflush(stdout);
return retval;
}
Expand Down
6 changes: 6 additions & 0 deletions Src/AssemblyInfoForTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
// Set stub for messagebox so that we don't pop up a message box when running tests.
[assembly: SetMessageBoxAdapter]

// Log last-chance managed exceptions to console output before process termination.
[assembly: LogUnhandledExceptions]

// Suppress all assertion dialog boxes (native + managed) regardless of config file coverage
[assembly: SuppressAssertDialogs]

// Cleanup all singletons after running tests
[assembly: CleanupSingletons]

Expand Down
6 changes: 6 additions & 0 deletions Src/AssemblyInfoForUiIndependentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
// This file is for test fixtures for UI independent projects, i.e. projects that don't
// reference System.Windows.Forms et al.

// Log last-chance managed exceptions to console output before process termination.
[assembly: LogUnhandledExceptions]

// Suppress all assertion dialog boxes (native + managed) regardless of config file coverage
[assembly: SuppressAssertDialogs]

// Cleanup all singletons after running tests
[assembly: CleanupSingletons]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ public override void AfterTest(ITest test)

private void OnThreadException(object sender, ThreadExceptionEventArgs e)
{
Console.Error.WriteLine("Unhandled Windows Forms thread exception during test run:");
Console.Error.WriteLine(e.Exception.ToString());
Console.Error.Flush();

throw new ApplicationException(e.Exception.Message, e.Exception);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) 2026 SIL International
// This software is licensed under the LGPL, version 2.1 or later
// (http://www.gnu.org/licenses/lgpl-2.1.html)

using System;
using System.Collections.Concurrent;
using System.Text;
using System.Threading.Tasks;
using NUnit.Framework;
using NUnit.Framework.Interfaces;

namespace SIL.FieldWorks.Common.FwUtils.Attributes
{
/// <summary>
/// Assembly-level test bootstrap that logs last-chance managed exceptions to the
/// console so unattended test runs always have readable failure details.
///
/// This is intentionally a logging-only hook. It does not try to recover or keep
/// the process alive after an unhandled exception.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly)]
public class LogUnhandledExceptionsAttribute : TestActionAttribute
{
private static readonly ConcurrentQueue<AggregateException> s_unobservedTaskExceptions =
new ConcurrentQueue<AggregateException>();

private UnhandledExceptionEventHandler m_unhandledExceptionHandler;
private EventHandler<UnobservedTaskExceptionEventArgs> m_unobservedTaskExceptionHandler;

/// <summary/>
public override ActionTargets Targets => ActionTargets.Suite;

/// <summary/>
public override void BeforeTest(ITest test)
{
base.BeforeTest(test);
ResetCapturedUnobservedTaskExceptionsForTesting();

m_unhandledExceptionHandler = OnUnhandledException;
AppDomain.CurrentDomain.UnhandledException += m_unhandledExceptionHandler;

m_unobservedTaskExceptionHandler = OnUnobservedTaskException;
TaskScheduler.UnobservedTaskException += m_unobservedTaskExceptionHandler;
}

/// <summary/>
public override void AfterTest(ITest test)
{
var unobservedTaskExceptions = FlushAndDrainCapturedUnobservedTaskExceptions();

if (m_unhandledExceptionHandler != null)
{
AppDomain.CurrentDomain.UnhandledException -= m_unhandledExceptionHandler;
m_unhandledExceptionHandler = null;
}

if (m_unobservedTaskExceptionHandler != null)
{
TaskScheduler.UnobservedTaskException -= m_unobservedTaskExceptionHandler;
m_unobservedTaskExceptionHandler = null;
}

base.AfterTest(test);

ThrowIfCapturedUnobservedTaskExceptions(unobservedTaskExceptions);
}

private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
Console.Error.WriteLine("Unhandled managed exception during test run:");
Console.Error.WriteLine($"IsTerminating: {e.IsTerminating}");
Console.Error.WriteLine(e.ExceptionObject?.ToString() ?? "<null exception object>");
Console.Error.Flush();
}

internal static void OnUnobservedTaskException(
object sender,
UnobservedTaskExceptionEventArgs e
)
{
Console.Error.WriteLine("Unobserved task exception during test run:");
Console.Error.WriteLine(e.Exception.ToString());
Console.Error.Flush();

s_unobservedTaskExceptions.Enqueue(e.Exception);
e.SetObserved();
}

internal static void ResetCapturedUnobservedTaskExceptionsForTesting()
{
AggregateException ignored;
while (s_unobservedTaskExceptions.TryDequeue(out ignored)) { }
}

internal static AggregateException[] FlushAndDrainCapturedUnobservedTaskExceptions()
{
for (int i = 0; i < 3; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}

return DrainCapturedUnobservedTaskExceptions();
}

internal static AggregateException[] DrainCapturedUnobservedTaskExceptions()
{
var exceptions = new ConcurrentQueue<AggregateException>();
AggregateException capturedException;
while (s_unobservedTaskExceptions.TryDequeue(out capturedException))
exceptions.Enqueue(capturedException);

return exceptions.ToArray();
}

internal static void ThrowIfCapturedUnobservedTaskExceptions(
AggregateException[] exceptions
)
{
if (exceptions == null || exceptions.Length == 0)
return;

throw new AssertionException(BuildFailureMessage(exceptions));
}

internal static string BuildFailureMessage(AggregateException[] exceptions)
{
var builder = new StringBuilder();
builder.AppendLine(
string.Format(
"{0} unobserved task exception(s) were detected during the test run.",
exceptions.Length
)
);
builder.AppendLine(
"These exceptions were captured from TaskScheduler.UnobservedTaskException and surfaced at suite teardown so the test host fails deterministically without crashing on the finalizer thread."
);

for (int i = 0; i < exceptions.Length; i++)
{
builder.AppendLine();
builder.AppendLine(string.Format("[{0}] {1}", i + 1, exceptions[i]));
}

return builder.ToString();
}
}
}
Loading
Loading