diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 24784a085..0ff3a84ca 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1128,6 +1128,12 @@ jobs:
- name: Test
run: zig build -Dapp-runtime=none test
+ - name: Build ghostty.dll
+ run: zig build -Dapp-runtime=none -Demit-exe=false
+
+ - name: .NET interop tests
+ run: dotnet test windows/Ghostty.Tests/Ghostty.Tests.csproj
+
test-i18n:
strategy:
fail-fast: false
diff --git a/src/main_c.zig b/src/main_c.zig
index 953e1f4ec..a131de255 100644
--- a/src/main_c.zig
+++ b/src/main_c.zig
@@ -24,6 +24,11 @@ const internal_os = @import("os/main.zig");
// dereferences. Declaring DllMain causes Zig's start.zig to call it
// during DLL_PROCESS_ATTACH/DETACH, and we forward to the CRT bootstrap
// functions from libvcruntime and libucrt (already linked).
+//
+// This is a workaround. Zig handles MinGW DLLs correctly (via dllcrt2.obj)
+// but not MSVC. No upstream issue tracks this exact gap as of 2026-03-26.
+// Closest: Codeberg ziglang/zig #30936 (reimplement crt0 code).
+// Remove this DllMain when Zig handles MSVC DLL CRT init natively.
pub const DllMain = if (builtin.os.tag == .windows and
builtin.abi == .msvc) struct
{
@@ -59,6 +64,14 @@ pub const DllMain = if (builtin.os.tag == .windows and
}
}.handler else void;
+// Probe export: returns 1 when the DllMain CRT workaround above is
+// compiled in. The C# test suite checks for this symbol to detect when
+// the workaround becomes redundant (when Zig fixes MSVC DLL CRT init).
+// Remove this along with the DllMain block above.
+pub export fn ghostty_crt_workaround_active() callconv(.c) c_int {
+ return if (builtin.os.tag == .windows and builtin.abi == .msvc) 1 else 0;
+}
+
// Some comptime assertions that our C API depends on.
comptime {
// We allow tests to reference this file because we unit test
diff --git a/windows/Ghostty.Tests/Ghostty.Tests.csproj b/windows/Ghostty.Tests/Ghostty.Tests.csproj
new file mode 100644
index 000000000..632c025b9
--- /dev/null
+++ b/windows/Ghostty.Tests/Ghostty.Tests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ latest
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/windows/Ghostty.Tests/LibghosttyInitTests.cs b/windows/Ghostty.Tests/LibghosttyInitTests.cs
new file mode 100644
index 000000000..e57e13d55
--- /dev/null
+++ b/windows/Ghostty.Tests/LibghosttyInitTests.cs
@@ -0,0 +1,98 @@
+using System.Runtime.InteropServices;
+
+[assembly: System.Runtime.CompilerServices.DisableRuntimeMarshalling]
+
+namespace Ghostty.Tests;
+
+///
+/// Tests that validate libghostty DLL initialization on Windows.
+///
+/// Ghostty's main_c.zig declares a DllMain that calls __vcrt_initialize
+/// and __acrt_initialize because Zig's _DllMainCRTStartup does not
+/// initialize the MSVC C runtime for DLL targets. Without this, any C
+/// library function (setlocale, glslang, oniguruma) crashes.
+///
+/// See the probe test at the bottom for workaround tracking.
+///
+[TestClass]
+public partial class LibghosttyInitTests
+{
+ private const string LibName = "ghostty";
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct GhosttyInfo
+ {
+ public int BuildMode;
+ public nint Version;
+ public nuint VersionLen;
+ }
+
+ [LibraryImport(LibName, EntryPoint = "ghostty_info")]
+ private static partial GhosttyInfo GhosttyInfoNative();
+
+ [LibraryImport(LibName, EntryPoint = "ghostty_crt_workaround_active")]
+ private static partial int GhosttyWorkaroundActive();
+
+ [TestMethod]
+ public void GhosttyInfo_Works()
+ {
+ // Baseline: ghostty_info uses only compile-time constants and
+ // does not depend on CRT state. This should always work.
+ var info = GhosttyInfoNative();
+
+ Assert.IsGreaterThan((nuint)0, info.VersionLen);
+ Assert.AreNotEqual(nint.Zero, info.Version);
+ }
+
+ // NOTE: ghostty_init is validated by the C reproducer (test_dll_init.c)
+ // rather than a C# test because ghostty_init initializes global state
+ // (glslang, oniguruma, allocators) that crashes during test host
+ // teardown when the DLL is unloaded. The C reproducer handles this by
+ // exiting without FreeLibrary. The DLL unload ordering issue is
+ // separate from the CRT init fix.
+
+ ///
+ /// PROBE TEST: Detects when our DllMain CRT workaround in main_c.zig
+ /// is removed, which should happen when Zig fixes _DllMainCRTStartup
+ /// for MSVC DLL targets.
+ ///
+ /// HOW IT WORKS:
+ /// ghostty_crt_workaround_active() returns 1 when the workaround is
+ /// compiled in (Windows MSVC), 0 on other platforms. This test
+ /// asserts that it returns 1. When it returns 0, the workaround was
+ /// removed.
+ ///
+ /// WHEN THIS TEST FAILS:
+ /// Someone removed the DllMain workaround from main_c.zig.
+ /// This is the expected outcome when Zig fixes the issue.
+ ///
+ /// Step 1: Run test_dll_init.c (the C reproducer) WITHOUT the
+ /// DllMain workaround. If ghostty_init returns 0, Zig
+ /// fixed it. Delete the DllMain block in main_c.zig,
+ /// ghostty_crt_workaround_active(), and this probe test.
+ ///
+ /// Step 2: If ghostty_init still crashes without the workaround,
+ /// restore the DllMain block in main_c.zig.
+ ///
+ /// Step 3: To unblock CI while investigating, skip this test:
+ /// dotnet test --filter "FullyQualifiedName!=Ghostty.Tests.LibghosttyInitTests.DllMainWorkaround_IsStillActive"
+ ///
+ /// UPSTREAM TRACKING (as of 2026-03-26):
+ /// No Zig issue tracks this exact gap.
+ /// Closest: Codeberg ziglang/zig #30936 (reimplement crt0 code).
+ /// Related GitHub issues: 7065, 11285, 19672 (link-time, not runtime).
+ ///
+ [TestMethod]
+ public void DllMainWorkaround_IsStillActive()
+ {
+ var active = GhosttyWorkaroundActive();
+ Assert.AreEqual(1, active,
+ "ghostty_crt_workaround_active() returned 0. " +
+ "The DllMain CRT workaround in main_c.zig was removed or disabled. " +
+ "Run test_dll_init.c without the workaround to check if Zig fixed " +
+ "the issue. If ghostty_init works, delete the DllMain and this test. " +
+ "If it crashes, restore the DllMain. " +
+ "To skip this test: dotnet test --filter " +
+ "\"FullyQualifiedName!=Ghostty.Tests.LibghosttyInitTests.DllMainWorkaround_IsStillActive\"");
+ }
+}
diff --git a/windows/Ghostty.Tests/test_dll_init.c b/windows/Ghostty.Tests/test_dll_init.c
new file mode 100644
index 000000000..ccbc49772
--- /dev/null
+++ b/windows/Ghostty.Tests/test_dll_init.c
@@ -0,0 +1,54 @@
+/*
+ * Minimal reproducer for the libghostty DLL CRT initialization issue.
+ *
+ * Before the fix (DllMain calling __vcrt_initialize / __acrt_initialize),
+ * ghostty_init crashed with "access violation writing 0x0000000000000024"
+ * because Zig's _DllMainCRTStartup does not initialize the MSVC C runtime
+ * for DLL targets.
+ *
+ * Build: zig cc test_dll_init.c -o test_dll_init.exe -target native-native-msvc
+ * Run: copy ..\..\zig-out\lib\ghostty.dll . && test_dll_init.exe
+ *
+ * Expected output (after fix):
+ * ghostty_info:
+ * ghostty_init: 0
+ */
+
+#include
+#include
+
+typedef struct {
+ int build_mode;
+ const char *version;
+ size_t version_len;
+} ghostty_info_s;
+
+typedef ghostty_info_s (*ghostty_info_fn)(void);
+typedef int (*ghostty_init_fn)(size_t, char **);
+
+int main(void) {
+ HMODULE dll = LoadLibraryA("ghostty.dll");
+ if (!dll) {
+ fprintf(stderr, "LoadLibrary failed: %lu\n", GetLastError());
+ return 1;
+ }
+
+ ghostty_info_fn info_fn = (ghostty_info_fn)GetProcAddress(dll, "ghostty_info");
+ if (info_fn) {
+ ghostty_info_s info = info_fn();
+ fprintf(stderr, "ghostty_info: %.*s\n", (int)info.version_len, info.version);
+ }
+
+ ghostty_init_fn init_fn = (ghostty_init_fn)GetProcAddress(dll, "ghostty_init");
+ if (init_fn) {
+ char *argv[] = {"ghostty"};
+ int result = init_fn(1, argv);
+ fprintf(stderr, "ghostty_init: %d\n", result);
+ if (result != 0) return 1;
+ }
+
+ /* Skip FreeLibrary -- ghostty's global state cleanup and CRT
+ * teardown ordering is not yet handled. The OS reclaims everything
+ * on process exit. */
+ return 0;
+}