Windows CI cannot report static-initialisation faults
### Summary
Nix's Windows CI runs cross-compiled binaries under Wine, pinned to nixpkgs'
`wineWow64Packages.stable`. That channel currently resolves to Wine 11.0, which carries a
known x86-64 unwinder defect — WineHQ bug 59850, "Exit unwind is broken", fixed upstream in
Wine 11.13.
The practical effect is narrow but severe: **when a C++ exception escapes a DLL's static
initialiser, the process dies with no diagnostic at all** — no exception message, no module
name, and an exit status that carries no information about the cause. The same fault under a
Wine carrying the fix, or on real Windows, produces a message naming the failing DLL.
This is a CI observability problem, not a correctness problem in Nix. But it is the reason a
real Nix defect of exactly this shape can sit undetected: the CI job that would have caught it
reports nothing a reader can act on.
Suggested change is one line in `flake.nix:116`, replacing `wineWow64Packages.stable` with
`wineWow64Packages.unstable`.
### What the failure looks like
A cross-compiled Nix executable, run under Wine 11.0, exits with status 5 and zero bytes on
both stdout and stderr. With Wine's `seh` debug channel enabled, the terminal sequence is:
```
RtlUnwindEx code=20474343 flags=7 end_frame=0000000000000000 target_ip=0000000000000000
err:seh:RtlUnwindEx invalid frame 00007FFFFE300008 (00007FFFFE102000-00007FFFFE300000)
RtlRestoreContext returning to 0000000000000000 stack 00007FFFFE300008
dispatch_exception code=c0000005 (EXCEPTION_ACCESS_VIOLATION) flags=0 addr=0000000000000000
err:seh:NtRaiseException Exception frame is not in stack limits => unable to dispatch exception.
```
The unwinder walks 8 bytes past the top of the thread stack, resumes at instruction pointer 0,
and the resulting access violation cannot be dispatched because the stack pointer is already
out of bounds. Exit status 5 is `STATUS_ACCESS_VIOLATION & 0xFF` — an artefact of the crash,
not a value chosen by the program.
Note that the CI harness commonly sets `WINEDEBUG=-all`, which suppresses the `err` channel and
removes even these lines. Without them the failure is entirely opaque.
### Why nothing is reported
Two independent mechanisms compound. Only the second is a Wine defect.
**1. `std::terminate` is unreachable from a DLL initialiser. This is not Wine-specific.**
Wine's loader wraps each DLL entry point in a catch-all (`dlls/ntdll/loader.c`, in
`MODULE_InitDLL`), recording `GetExceptionCode()` on failure. libstdc++ reaches
`__verbose_terminate_handler` — the code that prints `terminate called after throwing an
instance of ...` — only after `_Unwind_RaiseException` *returns*, and on SEH targets it returns
only when nothing handled the exception (`libsupc++/eh_throw.cc`). Because a handler always
exists above a DLL's static initialisers, that message is structurally unreachable.
Confirmed by construction: a DLL that installs its own terminate handler in one static
initialiser and throws uncaught from the next never sees the handler run, while the identical
test in an executable does.
Windows' loader guards `DllMain` too, so this half applies on real Windows as well. No Wine
change affects it.
**2. Bug 59850 destroys the one diagnostic that does exist. This half is Wine-only.**
Wine's loader would otherwise print, naming the culprit:
```
err:module:loader_init "<name>.dll" failed to initialize, aborting
err:module:loader_init Initializing dlls for L"...\<program>.exe" failed, status 20474343
```
That handler is registered on the TEB exception list — Wine's `__EXCEPT_ALL` expands to
`__EXCEPT_HANDLER(__wine_exception_handler_all)` and the surrounding macros call
`__wine_push_frame`, which sets `teb->ExceptionList`. `USE_COMPILER_EXCEPTIONS` is not defined
by Wine's build, so this is the live path.
In Wine 11.0, `RtlUnwindEx` walks that list under (`dlls/ntdll/signal_x86_64.c`):
```c
else /* hack: call builtin handlers registered in the tib list */
{
while (is_valid_frame( (ULONG_PTR)teb_frame ) &&
(ULONG64)teb_frame < new_context.Rsp &&
(ULONG64)teb_frame < (ULONG64)end_frame)
```
On an exit unwind `end_frame` is null, so `teb_frame < 0` is never true and the loop body never
executes. No TEB-registered handler is invoked, including the loader's. The commit that fixes
this is titled "ntdll: Fix handling of exit unwind when there are no TEB frames", which is the
same condition described from the other side.
The fix bounds the walk by a separate variable and applies `end_frame` only when non-null:
```c
ULONG_PTR last_frame = new_context.Rsp;
if (end_frame && (ULONG_PTR)end_frame < last_frame) last_frame = (ULONG_PTR)end_frame;
while (is_valid_frame( (ULONG_PTR)teb_frame ) && (ULONG_PTR)teb_frame < last_frame)
```
**Where the null `end_frame` comes from, and why it is correct.** libgcc writes the unwind
target into `private_[1]` and `private_[2]` only inside the `_URC_HANDLER_FOUND` branch of
`_GCC_specific_handler` (`libgcc/unwind-seh.c`), and zeroes them at throw time. With no C++
handler anywhere on the stack — the case before `main` — that branch never runs, so
`_Unwind_Resume` passes zeros to `RtlUnwindEx`. Requesting an exit unwind with a null target
frame is documented and correct. The defect is in honouring the request, not in making it.
### Why essentially every real case hits the bad path
A throw with no cleanup frames between it and the loader is caught in phase 1 and reported
normally, exiting 67 (`0x20474343 & 0xFF`) with the DLL named. The exit unwind only occurs when
a cleanup landing pad runs, because every landing pad ends in `_Unwind_Resume`.
**One cleanup frame is enough**, and the number of target-less exit unwinds equals the number of
cleanup frames — measured as 0, 1, 3 across three synthetic binaries and 2 for a real Nix
executable whose two cleanup pads were identified by symbol.
Since essentially every real static initialiser constructs an object with a destructor, the
well-behaved case that produces a usable diagnostic is the one almost nobody encounters.
### Why the CI will not pick the fix up on its own
`flake.nix:116` pins `wineWow64Packages.stable`. In nixpkgs at master,
`pkgs/applications/emulators/wine/sources.nix:108` sets that channel's version to **11.0** —
the same version affected. The channel tracks Wine's `$major.0` series by design; the update
script resolves `$major.0` explicitly rather than the newest `$major.x`.
Wine has cut no point release on either series in scope. Verified by tag existence, with a
control to confirm the naming convention is right:
```
wine-9.0.1 EXISTS <- control: Wine does cut $major.0.x point releases
wine-11.13 EXISTS <- the release carrying the fix
wine-11.0 EXISTS
wine-10.0 EXISTS
wine-11.0.1 absent
wine-11.0.2 absent
wine-10.0.1 absent
wine-10.0.2 absent
```
Stated conservatively, and deliberately not as a prediction: **no released `stable` has carried
this fix, none is reachable through that channel at any nixpkgs revision today, and nothing
observed indicates a point release carrying it is planned.** The regression it fixes dates from
2024-06-20, so this has been the state of the pinned emulator across both `$major.0` releases in
scope. Whether and when a future `stable` picks it up is not something this report can tell you —
the next `$major.0` is the earliest candidate, and any date attached to that would be
extrapolation from release cadence rather than a schedule.
### The `unstable` channel is not sufficient on its own — check this before applying the change
`wineWow64Packages.unstable` is **11.8** at the nixpkgs revision this flake actually pins
(`flake.lock` → nixpkgs `a50de1b7d8a586adc18d2395c19de7d6058e6030`, giving
`pkgs/applications/emulators/wine/sources.nix:154`). **11.8 is below 11.13, so it does not carry
the fix.** At nixpkgs master the same attribute is 11.15, which does.
**This is the difference between the change working and silently not working**, and it is easy to
miss because the diff below looks self-contained. Switching the channel without also advancing the
`nixpkgs` input buys nothing: 11.0 and 11.8 are both pre-fix, so CI stays exactly as silent as it
is now while appearing to have been addressed.
So the change is two parts, not one: move the channel **and** advance the pinned `nixpkgs` far
enough that `unstable` resolves past 11.13. Verify after applying, rather than assuming —
`wine --version` under the built emulator is the check.
Both figures above are read from `sources.nix` at the two revisions named. The `stable = 11.0`
claim earlier in this report comes from `sources.nix:108` at the same pinned revision.
### Suggested change
```diff
-emulator = pkgs: "${pkgs.buildPackages.wineWow64Packages.stable}/bin/wine";
+emulator = pkgs: "${pkgs.buildPackages.wineWow64Packages.unstable}/bin/wine";
```
Plus a `nixpkgs` input advance, per the section above. The diff alone is not the whole change.
Two further caveats worth weighing rather than dismissing.
`unstable` moves faster, which trades one kind of CI flakiness for another.
And there is a smaller, independent improvement available without touching the Wine version:
dropping `WINEDEBUG=-all` from the harness, or narrowing it so the `err` channel survives.
**This is strictly partial, and the difference matters.** It recovers the `err:seh` stack-limit
lines quoted above, which at least identify the failure as an unwinder problem rather than
nothing at all. It does **not** recover the failing DLL's name, because the loader's handler
still never runs — that is the bug-59850 half, and only a Wine carrying the fix restores it. So
the two options are not equivalent: the cheap one turns silence into an unattributed unwinder
error, while the Wine bump turns it into a named module.
### Two separate defects — please do not conflate them
This report is about the second only.
1. **A Nix defect.** An exception is thrown over genuinely invalid input during static
initialisation. Fixing Wine does not fix it; the program will still fail, just legibly.
2. **A reporting defect.** Bug 59850 turns that failure from "names the failing DLL and exits
67" into "exits 5 with zero output".
A reader who merges these will conclude the Wine bump makes the affected code work. It does
not. It makes the failure diagnosable.
### What was tested, and what was not
**Tested directly:**
- The failure sequence quoted above, reproduced repeatedly under Wine 11.0 in a booted prefix.
- The Wine 11.0 guard, read from the source of the exact build in use rather than an upstream
tag — and confirmed byte-identical to the `wine-11.0` tag.
- The `libgcc` `private_` handling, read from the source of the exact compiler that produced
the binaries.
- The fixed guard, read at the `wine-11.13` tag.
- The release bracket: the identifier the fix introduces is absent at `wine-11.12` and present
at `wine-11.13` and `wine-11.15`.
- Tag existence for the point releases listed above, including the `wine-9.0.1` control.
- `sources.nix` at nixpkgs master, for both channel versions.
- The cleanup-frame-count relationship, across three synthetic binaries and one real one.
- That an executable-side static-init throw *does* print and exit 3, where the DLL-side case
does not.
**Not tested, and stated as inference:**
- **That this cannot happen on real Windows.** x86-64 Windows SEH is table-based, and the
failing comparison is in a Wine-internal TEB-list walk that Wine's own comment marks as a
workaround, in a branch with no counterpart in Windows' dispatcher. That is a strong argument
and it is still an argument — **no measurement on Windows hardware was performed, and none was
possible here.**
- **When `stable` might carry the fix.** The next `$major.0` release is the earliest candidate.
Any date attached to that is extrapolation from release cadence, not a schedule.
- **Whether a future 11.0.x point release might backport it.** None exists today; nothing
observed indicates one is planned.
- Behaviour under `unstable` was not exercised end to end. The suggested change is untested.
- Nothing was run under a Wine carrying the fix. Every statement about post-fix behaviour is
read from source, not observed.
3 条评论