diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cd8466e73..69240e8c7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,6 +207,11 @@ jobs: MINGW_PKG_PREFIX: x86_64-w64-mingw32 MINGW_ASM_MASM_COMPILER: llvm-ml MINGW_ASM_MASM_FLAGS: -m64 + - name: Wine (Clang 21 + MinGW-w64) + os: ubuntu-26.04 + CC: clang-21 + CXX: clang++-21 + TEST_WINE: 1 - name: Android (API 21, NDK 23) os: ubuntu-latest ANDROID_API: 21 @@ -237,6 +242,7 @@ jobs: CXX: ${{ matrix.CXX }} TEST_X86: ${{ matrix.TEST_X86 }} TEST_MINGW: ${{ matrix.TEST_MINGW }} + TEST_WINE: ${{ matrix.TEST_WINE }} TEST_ARM32: ${{ matrix.TEST_ARM32 }} TEST_QEMU: ${{ matrix.TEST_QEMU }} USE_SCCACHE: ${{ matrix.USE_SCCACHE }} @@ -295,6 +301,17 @@ jobs: exit 1 fi + - name: Installing Wine Dependencies + if: ${{ env['TEST_WINE'] }} + run: | + sudo install -d -m 0755 /etc/apt/keyrings + sudo curl -fsSL -o /etc/apt/keyrings/winehq-archive.asc https://dl.winehq.org/wine-builds/winehq.key + sudo curl -fsSL -o /etc/apt/sources.list.d/winehq-resolute.sources https://dl.winehq.org/wine-builds/ubuntu/dists/resolute/winehq-resolute.sources + sudo sed -i 's|winehq-archive\.key|winehq-archive.asc|' /etc/apt/sources.list.d/winehq-resolute.sources + sudo apt-get update + sudo apt-get install --install-recommends winehq-stable mingw-w64 lld + wine --version + - name: Installing kcov dependencies if: ${{ contains(env['RUN_ANALYZER'], 'kcov') }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 98eacde709..d7def99d61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Native: store daemon logs, minidumps, crash envelopes, and scratch files in `.run` directories so they are cleaned up with the run instead of accumulating in the database root. Minidumps can still be retained with `cache_keep`, which stores `.dmp` sidecars alongside cached envelopes. ([#1976](https://github.com/getsentry/sentry-native/pull/1976)) - Linux/ARM32: prevent recursive crashes when libunwind receives an unmapped initial instruction pointer during crash handling. ([#1977](https://github.com/getsentry/sentry-native/pull/1977)) +- Wine: fix OS version detection and cross-compiling Windows builds from Linux. ([#2001](https://github.com/getsentry/sentry-native/pull/2001)) ## 0.16.3 diff --git a/examples/example.c b/examples/example.c index 005a8f36dd..1c4cc3da42 100644 --- a/examples/example.c +++ b/examples/example.c @@ -1316,7 +1316,7 @@ main(int argc, char **argv) assert(0); } if (has_arg(argc, argv, "abort")) { -#ifdef _WIN32 +#if defined(_WIN32) && (defined(_MSC_VER) || defined(_UCRT)) // Suppress the Windows abort dialog that would block CI _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); #endif diff --git a/src/modulefinder/sentry_modulefinder_windows.c b/src/modulefinder/sentry_modulefinder_windows.c index ea3d82008c..88f5e4ace7 100644 --- a/src/modulefinder/sentry_modulefinder_windows.c +++ b/src/modulefinder/sentry_modulefinder_windows.c @@ -8,8 +8,8 @@ # include #endif #define PSAPI_VERSION 2 -#include -#include +#include +#include static bool g_initialized = false; static sentry_mutex_t g_mutex = SENTRY__MUTEX_INIT; diff --git a/src/sentry_os.c b/src/sentry_os.c index e1bb18cb94..cd8ed25532 100644 --- a/src/sentry_os.c +++ b/src/sentry_os.c @@ -167,12 +167,11 @@ sentry__get_windows_version(windows_version_t *win_ver) } win_ver->build = strtoul(buf, NULL, 10); + // UBR (Update Build Revision) is optional (not present on Wine) + reg_version = 0; buf_size = sizeof(uint32_t); - if (RegGetValueA(HKEY_LOCAL_MACHINE, CURRENT_VERSION, "UBR", - RRF_RT_REG_DWORD, NULL, ®_version, &buf_size) - != ERROR_SUCCESS) { - return 0; - } + RegGetValueA(HKEY_LOCAL_MACHINE, CURRENT_VERSION, "UBR", RRF_RT_REG_DWORD, + NULL, ®_version, &buf_size); win_ver->ubr = reg_version; return 1; diff --git a/tests/__init__.py b/tests/__init__.py index df299728d7..5aa9af156f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -20,6 +20,19 @@ def adb(*args, **kwargs): ) +def exe_name(name): + if sys.platform == "win32" or os.environ.get("TEST_WINE"): + return f"{name}.exe" + return name + + +def run_command(name): + name = exe_name(name) + if os.environ.get("TEST_WINE"): + return ["wine", name] + return [name] + + # https://docs.pytest.org/en/latest/assert.html#assert-details pytest.register_assert_rewrite("tests.assertions") @@ -191,9 +204,10 @@ def run( ) return child - cmd = [ - "./{}".format(exe) if sys.platform != "win32" else "{}\\{}.exe".format(cwd, exe) - ] + executable = ( + "./{}".format(exe) if sys.platform != "win32" else "{}\\{}".format(cwd, exe) + ) + cmd = run_command(executable) if "asan" in os.environ.get("RUN_ANALYZER", ""): asan_options = env.get("ASAN_OPTIONS", "") if "detect_leaks" not in asan_options: diff --git a/tests/assertions.py b/tests/assertions.py index b5cce57cbc..44ce4f5640 100644 --- a/tests/assertions.py +++ b/tests/assertions.py @@ -13,7 +13,7 @@ import msgpack from . import REPLAY_ID, SENTRY_VERSION -from .conditions import is_android, is_asan, is_tsan +from .conditions import is_android, is_asan, is_tsan, is_wine VERSION_RE = re.compile(r"(\d+\.\d+\.\d+)[-.]?(.*)") @@ -131,11 +131,12 @@ def assert_event_meta( if is_android: expected_sdk["name"] = "sentry.native.android" else: - if sys.platform == "win32": - assert_matches( - event["contexts"]["os"], - {"name": "Windows", "version": platform.version()}, - ) + if sys.platform == "win32" or is_wine: + expected_os_context = {"name": "Windows"} + if not is_wine: + expected_os_context["version"] = platform.version() + assert_matches(event["contexts"]["os"], expected_os_context) + assert event["contexts"]["os"]["version"] is not None assert event["contexts"]["os"]["build"] is not None elif sys.platform == "linux": version = platform.release() diff --git a/tests/build_config.py b/tests/build_config.py index 850a348d9e..fd11279a16 100644 --- a/tests/build_config.py +++ b/tests/build_config.py @@ -42,6 +42,7 @@ def get_platform_cmake_args(): This handles: - 32-bit builds (TEST_X86) + - Wine cross-builds (TEST_WINE) - ASAN/TSAN sanitizers (RUN_ANALYZER) - Additional CMAKE_DEFINES from environment """ @@ -61,6 +62,24 @@ def get_platform_cmake_args(): "-DCMAKE_ASM_COMPILER=arm-linux-gnueabihf-gcc", ] ) + elif sys.platform == "linux" and os.environ.get("TEST_WINE"): + cc = os.environ.get("CC") or "clang" + cxx = os.environ.get("CXX") or "clang++" + linker_flags = "-fuse-ld=lld -static-libgcc -static-libstdc++" + args.extend( + [ + "-DCMAKE_SYSTEM_NAME=Windows", + "-DCMAKE_SYSTEM_VERSION=10.0", + f"-DCMAKE_C_COMPILER={cc}", + "-DCMAKE_C_COMPILER_TARGET=x86_64-w64-windows-gnu", + f"-DCMAKE_CXX_COMPILER={cxx}", + "-DCMAKE_CXX_COMPILER_TARGET=x86_64-w64-windows-gnu", + "-DCMAKE_RC_COMPILER=x86_64-w64-mingw32-windres", + f"-DCMAKE_EXE_LINKER_FLAGS={linker_flags}", + f"-DCMAKE_SHARED_LINKER_FLAGS={linker_flags}", + f"-DCMAKE_MODULE_LINKER_FLAGS={linker_flags}", + ] + ) if "asan" in os.environ.get("RUN_ANALYZER", ""): args.append("-DWITH_ASAN_OPTION=ON") diff --git a/tests/cmake.py b/tests/cmake.py index 305d784b5c..6885cf4de1 100644 --- a/tests/cmake.py +++ b/tests/cmake.py @@ -8,7 +8,7 @@ import pytest -from . import adb +from . import adb, exe_name from .conditions import has_sccache from .build_config import ( get_android_config, @@ -73,9 +73,6 @@ def destroy(self): if "llvm-cov" in os.environ.get("RUN_ANALYZER", ""): - def exe_name(name): - return name + ".exe" if sys.platform == "win32" else name - def lib_name(name): if sys.platform == "win32": return name + ".dll" diff --git a/tests/conditions.py b/tests/conditions.py index 4e10e4c350..094810abcf 100644 --- a/tests/conditions.py +++ b/tests/conditions.py @@ -4,6 +4,7 @@ is_aix = sys.platform == "aix" or sys.platform == "os400" is_android = os.environ.get("ANDROID_API") +is_wine = bool(os.environ.get("TEST_WINE")) is_x86 = os.environ.get("TEST_X86") is_arm32 = bool(os.environ.get("TEST_ARM32")) is_qemu = bool(os.environ.get("TEST_QEMU")) @@ -29,7 +30,7 @@ # breakpad accesses thread state registers directly, which doesn't work on arm64e and not (is_arm64e and sys.platform == "darwin") ) -# crashpad requires http, needs porting to AIX, and doesn’t work with kcov/valgrind/tsan either +# crashpad requires http, needs porting to AIX, and doesn’t work with kcov/valgrind/tsan or Wine cross-builds has_crashpad = ( has_http and not is_valgrind @@ -37,6 +38,7 @@ and not is_android and not is_aix and not is_tsan + and not is_wine ) # android has no local filesystem has_files = not is_android diff --git a/tests/conftest.py b/tests/conftest.py index a30a0d232e..a743ff266f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ from datetime import datetime, timedelta, UTC from . import adb, run from .cmake import CMake -from .conditions import has_sccache, is_android +from .conditions import has_sccache, is_android, is_wine import tests LABEL = "label" @@ -211,6 +211,12 @@ def pytest_sessionfinish(session, exitstatus): def pytest_sessionstart(session): + if is_wine: + # Keep Wine diagnostics from obscuring test output. + os.environ.setdefault("WINEDEBUG", "-all") + # Prevent intentional crashes from opening WineDbg and fresh prefixes + # from prompting to install Wine Mono. + os.environ.setdefault("WINEDLLOVERRIDES", "winedbg.exe=d;mscoree=d") if has_sccache: subprocess.run(["sccache", "--zero-stats"], capture_output=True) diff --git a/tests/proxy_server.py b/tests/proxy_server.py index 2a3d52c0a0..78628372a1 100644 --- a/tests/proxy_server.py +++ b/tests/proxy_server.py @@ -169,6 +169,7 @@ def _forward_http(client, proxy_auth): lines = header.decode("iso-8859-1").split("\r\n") method, target, version = lines[0].split(" ", 2) headers = _parse_headers(lines[1:]) + body = _read_http_body(client, headers, initial_body) if not _auth_matches(headers, proxy_auth): _send_proxy_auth_required(client, method) @@ -188,7 +189,6 @@ def _forward_http(client, proxy_auth): if url.query: path += "?" + url.query - body = _read_http_body(client, headers, initial_body) outbound_headers = [ (name, value) for name, value in headers diff --git a/tests/test_build_static.py b/tests/test_build_static.py index bda4a2227c..950ed334f6 100644 --- a/tests/test_build_static.py +++ b/tests/test_build_static.py @@ -2,7 +2,14 @@ import sys import os import pytest -from .conditions import has_breakpad, has_crashpad, has_native, is_android, is_qemu +from .conditions import ( + has_breakpad, + has_crashpad, + has_native, + is_android, + is_qemu, + is_wine, +) def test_static_lib(cmake): @@ -16,12 +23,12 @@ def test_static_lib(cmake): ) # on linux we can use `ldd` to check that we don’t link to `libsentry.so` - if sys.platform == "linux" and not is_android and not is_qemu: + if sys.platform == "linux" and not is_android and not is_qemu and not is_wine: output = subprocess.check_output("ldd sentry_example", cwd=tmp_path, shell=True) assert b"libsentry.so" not in output # on windows, we read the file header to check that the exe is compiled correctly - if sys.platform == "win32": + if sys.platform == "win32" or is_wine: with open(os.path.join(tmp_path, "sentry_example.exe"), "rb") as binary: binary.seek(0x3C, 0) offset = int.from_bytes(binary.read(4), byteorder="little") @@ -36,7 +43,7 @@ def test_static_lib(cmake): expected = b"PE\x00\x00\x64\x86" # IMAGE_FILE_MACHINE_AMD64 assert magic == expected # similarly, we use `file` on linux - if sys.platform == "linux": + if sys.platform == "linux" and not is_wine: output = subprocess.check_output( "file sentry_example", cwd=tmp_path, shell=True ) diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index ef3def7e94..8be0938ab3 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -9,10 +9,22 @@ from tests import adb from tests.assertions import wait_for -from tests.conditions import has_native, is_android, is_arm32, is_tsan, is_x86, is_asan +from tests.conditions import ( + has_native, + is_android, + is_arm32, + is_tsan, + is_wine, + is_x86, + is_asan, +) project_fixture_path = pathlib.Path("tests/fixtures/dotnet_signal") +pytestmark = pytest.mark.skipif( + is_wine, reason=".NET signal tests do not support Windows targets under Wine" +) + def assert_empty_run_dir(database_path): run_dirs = [d for d in database_path.glob("*.run") if d.is_dir()] diff --git a/tests/test_integration_http.py b/tests/test_integration_http.py index 18c2129b6b..7dff8db964 100644 --- a/tests/test_integration_http.py +++ b/tests/test_integration_http.py @@ -47,6 +47,7 @@ has_files, is_asan, is_qemu, + is_wine, ) @@ -360,7 +361,12 @@ def test_external_crash_reporter_http(cmake, httpserver, build_args): crash = crash_request.get_data() envelope = Envelope.deserialize(crash) - assert envelope.headers["cache_dir"] == str(cache_dir) + actual_cache_dir = envelope.headers["cache_dir"] + if is_wine: + actual_cache_dir = subprocess.check_output( + ["winepath", "-u", actual_cache_dir], text=True + ).strip() + assert actual_cache_dir == str(cache_dir) assert_meta(envelope, integration=build_args.get("SENTRY_BACKEND", "")) envelope = Envelope.deserialize(feedback) diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index 4cae17405e..bb9edbaed6 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -21,6 +21,7 @@ run, run_crash, stage_replay, + run_command, Envelope, split_log_request_cond, REPLAY_ID, @@ -37,7 +38,7 @@ wait_for_file, assert_user_feedback, ) -from .conditions import has_native, has_oom, is_asan, is_tsan, is_qemu +from .conditions import has_native, has_oom, is_asan, is_tsan, is_qemu, is_wine pytestmark = pytest.mark.skipif( not has_native or is_qemu, @@ -148,6 +149,7 @@ def test_native_oom(cmake, httpserver): ), ], ) +@pytest.mark.skipif(is_wine, reason="Wine does not terminate after stack overflow") def test_native_stack_overflow(cmake, httpserver, stack_size): """Test stack overflow crash capture with native backend""" env = dict(os.environ) @@ -318,11 +320,9 @@ def test_native_attachment_manifest_is_current(cmake, httpserver): """ tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "native"}) - exe = tmp_path / ( - "sentry_example.exe" if sys.platform == "win32" else "sentry_example" - ) + cmd = run_command(str(tmp_path / "sentry_example")) child = subprocess.Popen( - [str(exe), "log", "attach-custom-filename", "sleep"], + [*cmd, "log", "attach-custom-filename", "sleep"], cwd=tmp_path, env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver)), ) @@ -855,6 +855,10 @@ def _codesign_for_task_for_pid(*paths): ) +@pytest.mark.skipif( + is_wine, + reason="Wine does not capture indirectly referenced minidump memory", +) def test_native_smart_mode_captures_indirect_heap_memory(cmake, httpserver): """ Verify SMART minidump mode captures memory regions referenced by the @@ -964,11 +968,9 @@ def in_any(addr, ranges): def test_native_uses_existing_run(cmake): """The daemon adopts the existing run instead of creating root artifacts.""" tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "native"}) - exe = tmp_path / ( - "sentry_example.exe" if sys.platform == "win32" else "sentry_example" - ) + cmd = run_command(str(tmp_path / "sentry_example")) child = subprocess.Popen( - [str(exe), "log", "sleep"], + [*cmd, "log", "sleep"], cwd=tmp_path, env=dict(os.environ, SENTRY_DSN="https://foo@sentry.invalid/42"), ) @@ -1068,7 +1070,12 @@ def test_native_external_crash_reporter(cmake, httpserver): # Verify it's a minidump crash report and user feedback envelope = Envelope.deserialize(crash) - assert envelope.headers["cache_dir"] == str(cache_dir) + actual_cache_dir = envelope.headers["cache_dir"] + if is_wine: + actual_cache_dir = subprocess.check_output( + ["winepath", "-u", actual_cache_dir], text=True + ).strip() + assert actual_cache_dir == str(cache_dir) assert_meta(envelope, integration="native") assert_breadcrumb(envelope) diff --git a/tests/test_integration_proxy.py b/tests/test_integration_proxy.py index 2eced91a64..900d19d35a 100644 --- a/tests/test_integration_proxy.py +++ b/tests/test_integration_proxy.py @@ -10,7 +10,7 @@ from .assertions import ( assert_failed_proxy_auth_request, ) -from .conditions import has_http +from .conditions import has_http, is_wine from .proxy import ( closed_port, start_proxy, @@ -131,6 +131,7 @@ def test_proxy_auth_incorrect(cmake, httpserver): ) +@pytest.mark.skipif(is_wine, reason="Wine WinHTTP does not support IPv6 proxies") def test_proxy_ipv6(cmake, httpserver): proxy_process = None # store the proxy process to terminate it later try: @@ -197,7 +198,7 @@ def test_proxy_https_not_http(cmake, httpserver): pytest.param( ["socks5-proxy"], marks=pytest.mark.skipif( - sys.platform not in ["darwin", "linux"], + sys.platform not in ["darwin", "linux"] or is_wine, reason="SOCKS5 proxy tests are only supported on macOS and Linux", ), ), diff --git a/tests/test_integration_stdout.py b/tests/test_integration_stdout.py index 54485adc82..54ea52fafe 100644 --- a/tests/test_integration_stdout.py +++ b/tests/test_integration_stdout.py @@ -6,7 +6,7 @@ import pytest -from . import check_output, run, Envelope +from . import check_output, run, run_command, Envelope from .assertions import ( assert_attachment, assert_meta, @@ -22,7 +22,7 @@ assert_exception, wait_for, ) -from .conditions import has_breakpad, has_files, is_qemu +from .conditions import has_breakpad, has_files, is_qemu, is_wine @pytest.mark.skipif(is_qemu, reason="unreliable under qemu-user") @@ -123,15 +123,10 @@ def test_multi_process(cmake): cwd = tmp_path.joinpath("unicode ❤️ Юля") cwd.mkdir() - exe = "sentry_example" - cmd = ( - "../{}".format(exe) - if sys.platform != "win32" - else "{}\\{}.exe".format(tmp_path, exe) - ) + cmd = run_command(str(tmp_path / "sentry_example")) - child1 = subprocess.Popen([cmd, "sleep"], cwd=cwd) - child2 = subprocess.Popen([cmd, "sleep"], cwd=cwd) + child1 = subprocess.Popen([*cmd, "sleep"], cwd=cwd) + child2 = subprocess.Popen([*cmd, "sleep"], cwd=cwd) def list_db(suffix): try: @@ -153,7 +148,7 @@ def list_db(suffix): child2.wait() # and start another process that cleans up the old runs - subprocess.run([cmd], cwd=cwd) + subprocess.run(cmd, cwd=cwd) assert len(list_db(".run")) == 0 assert len(list_db(".lock")) == 0 @@ -278,6 +273,7 @@ def test_inproc_crash_stdout_before_send_and_on_crash(cmake): ), ], ) +@pytest.mark.skipif(is_wine, reason="Wine does not terminate after stack overflow") def test_inproc_stack_overflow_stdout(cmake, stack_size): env = dict(os.environ) if stack_size: @@ -373,6 +369,7 @@ def test_breakpad_crash_stdout_before_send_and_on_crash(cmake): ], ) @pytest.mark.skipif(not has_breakpad or is_qemu, reason="test needs breakpad backend") +@pytest.mark.skipif(is_wine, reason="Wine does not terminate after stack overflow") def test_breakpad_stack_overflow_stdout(cmake, stack_size): env = dict(os.environ) if stack_size: diff --git a/tests/test_stress_inproc.py b/tests/test_stress_inproc.py index a88529f0cb..41d64bdf23 100644 --- a/tests/test_stress_inproc.py +++ b/tests/test_stress_inproc.py @@ -11,10 +11,14 @@ from . import adb, Envelope from .assertions import assert_inproc_crash from .build_config import get_test_executable_cmake_args, get_test_executable_env -from .conditions import is_android, is_tsan +from .conditions import is_android, is_tsan, is_wine fixture_path = pathlib.Path("tests/fixtures/inproc_stress") +pytestmark = pytest.mark.skipif( + is_wine, reason="inproc stress test harness does not support Wine" +) + ANDROID_TMP = "/data/local/tmp" diff --git a/tests/test_unit.py b/tests/test_unit.py index a170e33116..4f418fb9fd 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -1,7 +1,7 @@ import os import pytest from . import run -from .conditions import has_http +from .conditions import has_http, is_wine def _skip_if_unsupported(unittest): diff --git a/tests/unit/test_value.c b/tests/unit/test_value.c index f461e2f9df..61bb84221b 100644 --- a/tests/unit/test_value.c +++ b/tests/unit/test_value.c @@ -1089,7 +1089,11 @@ SENTRY_TEST(value_stringify) STRINGIFY_AND_CHECK(rv, "3.14"); rv = sentry_value_new_double(1000000000000000); - STRINGIFY_AND_CHECK(rv, "1e+15"); + char *stringified = sentry__value_stringify(rv); + TEST_CHECK(strcmp(stringified, "1e+15") == 0 // msvcrt, libc, stbsp + || strcmp(stringified, "1e+015") == 0); // wine + sentry_free(stringified); + sentry_value_decref(rv); rv = sentry_value_new_double(INFINITY); STRINGIFY_AND_CHECK_CONTAINS(rv, "inf");