test
Crystal fibers and the MSVC C++ exception state, on Windows
Experiments around one fault. On Windows with the MSVC toolchain, a Crystal fiber that waits inside an ensure while another fiber on the same thread raises can end the process with 0xC0000409 when the ensure finishes. No rescue sees it, and nothing is printed. Crystal 1.21.0 and 1.22.2 both do it.
This repository holds a reproduction, two candidate remedies, and the tests that judge them. They run on GitHub's Windows runners (.github/workflows/windows.yml), or on any Windows machine with run.ps1.
What is going on
From Microsoft's runtime source and the 14.44 and 14.51 binaries:
- vcruntime keeps each thread's C++ exception state in one structure,
__vcrt_ptd(128 bytes on x64), held in a fiber-local storage (FLS) slot. Windows' own fibers each get their own, sinceSwitchToFiberswitches FLS. Crystal's fibers switch stacks themselves, so every Crystal fiber on a thread shares one. - With MSVC, Crystal runs an
ensurebody inside the catch funclet and then rethrows with_CxxThrowException(NULL, NULL). - Each catch pushes a record onto the chain at offset
0x58of that structure._CreateFrameInfolinks a new record only if it lies below the head, which on one stack always holds. - A fiber waiting in an
ensureis inside a catch, with its record at the head. If another fiber, whose stack lies above, catches meanwhile, its record starts a new chain, and popping it leaves the chain empty. When the first fiber'sensureends,_FindAndUnlinkFramedoes not find its record and callsabort, which ends with__fastfail:0xC0000409.
So the account predicts a crash only when the raiser's stack lies above the waiter's. T1's controls check that prediction.
Two remedies tried here
- R0 (
r0/cxx_fiber_state.cr): at every Crystal fiber switch, save the outgoing fiber's__vcrt_ptdand install the incoming one's, through vcruntime's own FLS slot, asSwitchToFiberwould. A dead fiber's structure goes to a reaper thread, which hands it back to vcruntime's own FLS callback by setting it in a Windows fiber and deleting that fiber. It hooksFiber.swapcontext, sets itself up fromCrystal.init_runtime, and stops the process (exit code 70, with a line on standard error) when it cannot run safely. It builds only for Crystal 1.21.0 to 1.22.2 (-Dcxx_fiber_state_unverifiedto build anyway), and admits only the vcruntime140.dll builds whose layout has been read: 14.44.35211.0 and 14.51.36247.0.CXX_FIBER_STATE=offturns it off;CXX_FIBER_STATE=trust-runtimeadmits another build.r0/cxx_fiber_detector.cradds checks of R0's invariant, for tests. - P2 (
p2/): a change to Crystal 1.22.2's compiler,src/compiler/crystal/codegen/exception.cr, that leaves the catch funclet before theensurebody runs and throws the saved exception again after it, so that no Crystal code runs inside a catch funclet.p2/exception.cris that file with the change, andp2/ensure-outside-funclet.patchthe change alone.
The experiments
| Name | What it shows |
|---|---|
T1 (tests/crash-door9.cr) |
The fault, in 13 modes: waits in an ensure, a rescue, nested and swapped; closing a pipe another fiber reads; with R0 every mode must finish |
T0 (tests/t0.cr) |
R0's own examples: set-up, fibers that never raise, separation, the first switch inside a catch, the last-error value, the opt-out |
T6 (tests/t6-ensure.cr) |
ensure semantics: a transcript that must match t6-expected.txt, made on macOS, with R0 on and off and with P2 |
T7 (tests/t7-migration.cr) |
Fibers moving between threads keep their own structure |
T8 (tests/t8-load.cr) |
Eight threads, a thousand fibers each, under the detector |
T9 (tests/t9-*.cr) |
Lifetimes: the process heap must not grow over ten batches of ten thousand fibers; a mutant that leaks must fail; then under full page heap |
T11 (t11/) |
Native C++ built four ways (FH3, FH4, /EHa, /MT) calling back into Crystal |
T12 (tests/t12-inject.cr) |
R0's own failures, injected: each must end the process within 2 s, also with standard error a full pipe |
T13 (tools/t13.py) |
From the release LLVM IR: the switch calls only what it may |
P1 (tests/p1-runtime.cr) |
The runtime as loaded, and the layout R0 relies on, checked in its disassembly |
P2 (tests/p2-timing.cr) |
The patched compiler against the stock one |
P3, P6 (tests/p3-identity.cr, tests/p6-ancestors.cr) |
One folder under several names, and its ancestors by identity |
P5 (tests/p5-cost.cr) |
R0's cost: channel ping-pong, yields, and fibers that raise and end |
run.ps1 prints each result as PASS, FAIL, RECORD (measured, not judged) or NEWS (against the prediction) in results\<date-time>\summary.txt.
Running
On a Windows machine with Visual Studio's C++ tools and Crystal for Windows (MSVC) in C:\crystal\1.22.2 and C:\crystal\1.21.0:
powershell -ExecutionPolicy Bypass -File run.ps1 # stages A and B
powershell -ExecutionPolicy Bypass -File run.ps1 -Stages E # elevated: page heap, a share, owners
The workflow runs on every push to main, and by hand from the Actions tab:
parse: parses the PowerShell with 5.1 and 7.linux: T6 on Linux, every program type-checked for Windows, and T13, with 1.21.0 and 1.22.2.windows: stages A, B and E onwindows-2022andwindows-2025, with each compiler. Each job's summary page shows itssummary.txt, and its results are an artifact.p2: downloads LLVM 23.1.1 for MSVC, builds the patched compiler, and runs P2.
Limits of the runners: they run elevated, so nothing here runs as a plain user; they are Windows Server, not Windows 10 or 11; and timings on shared machines are noisy, so P5 takes several runs each way and gives the spread.
Notes
p2/exception.cris a file of Crystal's compiler (Apache-2.0) with the change above.- No Microsoft code is kept here or uploaded. P1 reads vcruntime140.dll's disassembly on the runner and writes only facts about it; the crash dump stays on the runner too.
- This is an experiment, not a library.
test
- 0
- 0
- 0
- 0
- 0
- about 5 hours ago
- October 26, 2026
Fri, 13 Nov 2026 02:26:45 GMT