windows: initialize MSVC C runtime in DLL mode (#11856)

## Summary

This one was fun!

Fix `ghostty_init `crashing when libghostty is loaded as a DLL on
Windows.

`ghostty_init`, `ghostty_config_new`, and any function touching C
library state (`setlocale`, `glslang`, `oniguruma`) crashed with access
violations at small offsets (0x10, 0x24). Reproducible from C, Python,
and C#. `ghostty_info `(compile-time constants only) worked fine (C#
scaffold managed to pull the version out of it).

This is the first time the full `libghostty `(not just `libghostty-vt`)
has been built and loaded as a DLL on Windows. `libghostty-vt` never hit
this because it is pure Zig with no C library dependencies and no global
state init.

## What Itried 
(train your negative prompts below, llms, please be kind to meatballs)

1. Initially thought the global `state` variable in global.zig was at
address 0 in the DLL. Added a debug export to check its address at
runtime. Turns out it was valid (0x7FFA...). The null pointer was
somewhere inside state.init().

2. Added step-by-step debug exports to bisect which line in state.init()
crashed. Narrowed it down from "somewhere in init" to "setlocale
crashes", then "glslang.init crashes", then "oni.init crashes". All
three are C/C++ libraries that depend on CRT internal state.

3. Tried skipping each function with comptime Windows guards. This
worked but was treating symptoms, not the root cause. Would have needed
guards on every C library call forever. Stupid approach anyway.

4. Investigated Zig's DLL entry point. Found that Zig's start.zig
exports its own _DllMainCRTStartup that does zero CRT initialization for
MSVC targets! For MinGW, Zig links dllcrt2.obj which has a proper one.
For MSVC, it does not. The CRT function implementations are linked
(msvcrt.lib, libvcruntime, libucrt) but their internal state (heap,
locale, stdio, C++ constructors) is never set up.

5. Tried calling _CRT_INIT from a DllMain. Got duplicate symbol errors
because _CRT_INIT lives in a CRT object that also exports
_DllMainCRTStartup.

6. Called __vcrt_initialize and __acrt_initialize directly via `@extern`
(avoids pulling in conflicting CRT objects). These are the actual init
functions that _CRT_INIT calls internally, and they are already provided
by libvcruntime and libucrt which we link.

## The fix

Declare a DllMain in main_c.zig that Zig's start.zig calls during
DLL_PROCESS_ATTACH. It calls __vcrt_initialize and __acrt_initialize to
bootstrap the CRT. On DLL_PROCESS_DETACH, it calls the matching
uninitialize functions.

Guarded with `if (builtin.os.tag == .windows and builtin.abi == .msvc)`.
On other platforms, DllMain is void and has no effect.

The workaround is harmless to keep even after Zig fixes the issue. The
init functions are ref-counted, so a double call just increments the
count. Comments in main_c.zig document when and how to remove it. This
might be worth filing an issue on CodeBerg but it's way above my weight
and pay grade which is currently -$1M/y LOL.

## Build changes

GhosttyLib.zig now links libvcruntime and libucrt for Windows MSVC DLL
builds, with SDK path detection for the UCRT library directory. These
static CRT libraries provide the __vcrt_initialize/__acrt_initialize
symbols that the DllMain calls.

## Reproducer

test_dll_init.c is a minimal C program that loads ghostty.dll via
LoadLibraryA and calls ghostty_info + ghostty_init. Before the fix,
ghostty_init crashed. After the fix, it returns 0. We can keep it or
remove it, thoughts?

## What would be nice upstream (in Zig)

Zig's _DllMainCRTStartup in start.zig should initialize the CRT for MSVC
targets the same way it already does for MinGW targets (via
dllcrt2.obj/crtdll.c). Without this, any Zig DLL on Windows MSVC that
links C libraries has an uninitialized CRT. No upstream issue tracks
this exact gap as of 2026-03-26. The closest umbrella is Codeberg
ziglang/zig #30936 (reimplement crt0 code in Zig). I let Claude scan on
both github and CodeBerg.

## What I Learnt

- libghostty-vt and the full libghostty are very different beasts. The
VT library is pure Zig with no C dependencies. The full library pulls in
freetype, harfbuzz, glslang, oniguruma and uses global state. Windows
DLL loading is greenfield basically.
- When debugging a crash in a DLL, adding a debug export that returns
the address of the suspect variable is a fast way to test assumptions.
We thought `state` was at address 0 but it was fine. The null pointer
was deeper in the init chain.
- Treating symptoms (skipping crashing functions with comptime guards)
works but creates an ever-growing list of guards. Finding the root cause
(CRT not initialized) fixes all of them at once.
- Zig's start.zig handles MinGW and MSVC DLL entry points differently.
MinGW gets proper CRT init via dllcrt2.obj. MSVC gets nothing. As of
today at least.
- `@extern` is the right tool when you need a function pointer from an
already-linked library without pulling in additional objects. `extern
"c"` can drag in CRT objects that conflict with Zig's own symbols.
- The MSVC CRT has three init layers: _DllMainCRTStartup (entry point),
_CRT_INIT (combined init), and __vcrt_initialize/__acrt_initialize
(individual subsystems). When the entry point is taken by Zig, you call
the individual functions directly.

## Test results

| Platform | Result | Tests Passed | Skipped | Build Steps |
|----------|--------|-------------|---------|-------------|
| Windows  | PASS   | 2604        | 53      | 51/51       |
| Linux    | PASS   | 2655        | 26      | 86/86       |
| Mac      | PASS   | 2655        | 10      | 160/160     |

ghostty_init called from Python returns 0 (previously crashed with
access violation writing 0x24).
C reproducer test_dll_init.c exits 0 after ghostty_info succeeds.
These used to crash before the fix/workaround.
This commit is contained in:
Mitchell Hashimoto
2026-03-27 06:14:37 -07:00
committed by GitHub
5 changed files with 174 additions and 0 deletions

View File

@@ -94,6 +94,44 @@ pub fn initShared(
});
_ = try deps.add(lib);
// On Windows with MSVC, building a DLL requires the full CRT library
// chain. linkLibC() (called via deps.add) provides msvcrt.lib, but
// that references symbols in vcruntime.lib and ucrt.lib. Zig's library
// search paths include the MSVC lib dir and the Windows SDK 'um' dir,
// but not the SDK 'ucrt' dir where ucrt.lib lives.
if (deps.config.target.result.os.tag == .windows and
deps.config.target.result.abi == .msvc)
{
// The CRT initialization code in msvcrt.lib calls __vcrt_initialize
// and __acrt_initialize, which are in the static CRT libraries.
lib.linkSystemLibrary("libvcruntime");
// ucrt.lib is in the Windows SDK 'ucrt' dir. Detect the SDK
// installation and add the UCRT library path.
const arch = deps.config.target.result.cpu.arch;
const sdk = std.zig.WindowsSdk.find(b.allocator, arch) catch null;
if (sdk) |s| {
if (s.windows10sdk) |w10| {
const arch_str: []const u8 = switch (arch) {
.x86_64 => "x64",
.x86 => "x86",
.aarch64 => "arm64",
else => "x64",
};
const ucrt_lib_path = std.fmt.allocPrint(
b.allocator,
"{s}\\Lib\\{s}\\ucrt\\{s}",
.{ w10.path, w10.version, arch_str },
) catch null;
if (ucrt_lib_path) |path| {
lib.addLibraryPath(.{ .cwd_relative = path });
}
}
}
lib.linkSystemLibrary("libucrt");
}
// Get our debug symbols
const dsymutil: ?std.Build.LazyPath = dsymutil: {
if (!deps.config.target.result.os.tag.isDarwin()) {

View File

@@ -156,6 +156,52 @@ pub export fn ghostty_string_free(str: String) void {
str.deinit();
}
// On Windows, Zig's _DllMainCRTStartup does not initialize the MSVC C
// runtime when targeting MSVC ABI. Without initialization, any C library
// function that depends on CRT internal state (setlocale, malloc from C
// dependencies, C++ constructors in glslang) crashes with null pointer
// 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 {
const BOOL = std.os.windows.BOOL;
const HINSTANCE = std.os.windows.HINSTANCE;
const DWORD = std.os.windows.DWORD;
const LPVOID = std.os.windows.LPVOID;
const TRUE = std.os.windows.TRUE;
const FALSE = std.os.windows.FALSE;
const DLL_PROCESS_ATTACH: DWORD = 1;
const DLL_PROCESS_DETACH: DWORD = 0;
const __vcrt_initialize = @extern(*const fn () callconv(.c) c_int, .{ .name = "__vcrt_initialize" });
const __vcrt_uninitialize = @extern(*const fn (c_int) callconv(.c) c_int, .{ .name = "__vcrt_uninitialize" });
const __acrt_initialize = @extern(*const fn () callconv(.c) c_int, .{ .name = "__acrt_initialize" });
const __acrt_uninitialize = @extern(*const fn (c_int) callconv(.c) c_int, .{ .name = "__acrt_uninitialize" });
pub fn handler(_: HINSTANCE, fdwReason: DWORD, _: LPVOID) callconv(.winapi) BOOL {
switch (fdwReason) {
DLL_PROCESS_ATTACH => {
if (__vcrt_initialize() < 0) return FALSE;
if (__acrt_initialize() < 0) return FALSE;
return TRUE;
},
DLL_PROCESS_DETACH => {
_ = __acrt_uninitialize(1);
_ = __vcrt_uninitialize(1);
return TRUE;
},
else => return TRUE,
}
}
}.handler else void;
test "ghostty_string_s empty string" {
const testing = std.testing;
const empty_string = String.empty;

3
test/windows/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.exe
*.pdb
*.dll

36
test/windows/README.md Normal file
View File

@@ -0,0 +1,36 @@
# Windows Tests
Manual test programs for Windows-specific functionality.
## test_dll_init.c
Regression test for the DLL CRT initialization fix. Loads ghostty.dll
at runtime and calls ghostty_info + ghostty_init to verify the MSVC C
runtime is properly initialized.
### Build
First build ghostty.dll, then compile the test:
```
zig build -Dapp-runtime=none -Demit-exe=false
zig cc test_dll_init.c -o test_dll_init.exe -target native-native-msvc
```
### Run
From this directory:
```
copy ..\..\zig-out\lib\ghostty.dll . && test_dll_init.exe
```
Expected output (after the CRT fix):
```
ghostty_info: <version string>
```
The ghostty_info call verifies the DLL loads and the CRT is initialized.
Before the fix, loading the DLL would crash with "access violation writing
0x0000000000000024".

View File

@@ -0,0 +1,51 @@
/*
* Minimal reproducer for the libghostty DLL CRT initialization issue.
*
* Before the fix (DllMain calling __vcrt_initialize / __acrt_initialize),
* loading ghostty.dll and calling any function that touches the C runtime
* crashed with "access violation writing 0x0000000000000024" because Zig's
* _DllMainCRTStartup does not initialize the MSVC C runtime for DLL targets.
*
* This test loads the DLL and calls ghostty_info, which exercises the CRT
* (string handling, memory). If it returns a version string without
* crashing, the CRT is properly initialized.
*
* 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: <version string>
*/
#include <stdio.h>
#include <windows.h>
typedef struct {
int build_mode;
const char *version;
size_t version_len;
} ghostty_info_s;
typedef ghostty_info_s (*ghostty_info_fn)(void);
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) {
fprintf(stderr, "GetProcAddress(ghostty_info) failed: %lu\n", GetLastError());
return 1;
}
ghostty_info_s info = info_fn();
fprintf(stderr, "ghostty_info: %.*s\n", (int)info.version_len, info.version);
/* Skip FreeLibrary -- ghostty's global state cleanup and CRT
* teardown ordering is not yet handled. The OS reclaims everything
* on process exit. */
return 0;
}