Skip to content

Fix segfault on window close: let GLFW outlive the Vulkan objects - #443

Open
ozanyasindogan wants to merge 2 commits into
KhronosGroup:mainfrom
ozanyasindogan:fix/glfw-vulkan-destruction-order
Open

Fix segfault on window close: let GLFW outlive the Vulkan objects#443
ozanyasindogan wants to merge 2 commits into
KhronosGroup:mainfrom
ozanyasindogan:fix/glfw-vulkan-destruction-order

Conversation

@ozanyasindogan

@ozanyasindoganozanyasindogan commented Aug 8, 2026

Copy link
Copy Markdown

Fixes the segfault on window close reported in #138.

Problem

Every chapter that owns Vulkan objects crashes with SIGSEGV when the user closes the window. The program renders correctly the whole time and only dies during exit, which is why it is easy to miss.

#12 HelloTriangleApplication::~HelloTriangleApplication()
#11 vk::raii::SwapchainKHR::~SwapchainKHR()
#10 vk::raii::SwapchainKHR::clear() -> vkDestroySwapchainKHR
#9 libVkLayer_khronos_validation.so
#4 libnvidia-glcore.so -> Wayland WSI path
#3 wl_proxy_marshal_flags
#0 libwayland-client.so.0 <- SEGV_MAPERR

Root cause

run() ends with cleanup(), which calls glfwDestroyWindow() and glfwTerminate():

voidrun()
{
initWindow();
initVulkan();
mainLoop();
cleanup(); // <- tears GLFW down here
}

But the Vulkan objects are class members (vk::raii::SwapchainKHR, SurfaceKHR, Device, Instance, ...). Members are destroyed when the object dies, which is afterrun() returns. So the actual order is:

  1. window closed -> mainLoop() returns
  2. cleanup() calls glfwTerminate() -> window system connection and its proxies are freed
  3. run() returns, ~HelloTriangleApplication() runs
  4. ~SwapchainKHR() -> vkDestroySwapchainKHR -> the driver marshals a request on freed proxies -> crash

This is a regression from the RAII conversion. The pre-RAII code destroyed every Vulkan object by hand at the end of cleanup(), so glfwTerminate() genuinely ran last:

vkDestroySurfaceKHR(instance, surface, nullptr);
vkDestroyInstance(instance, nullptr);
glfwDestroyWindow(window);
glfwTerminate(); // correctly last

Moving to vk::raii removed those explicit calls - each one individually redundant - but left the two GLFW calls in cleanup(), silently inverting the order.

Fix

Give the GLFW lifetime to a guard declared first in the class. Members are destroyed in reverse declaration order, so first declared means last destroyed - after every Vulkan object, no matter how many later chapters add:

private:// Owns the GLFW lifetime. Declared first, so it is destroyed last - after// every vk::raii member below.structGlfwGuard
{
GlfwGuard()
{
if (!glfwInit())
{
throwstd::runtime_error("failed to initialize GLFW!");
}
}
~GlfwGuard() { glfwTerminate(); }
} glfwGuard;
GLFWwindow *window = nullptr;

glfwTerminate() also destroys any windows still open, so the separate glfwDestroyWindow() call is no longer needed.

The constructor was added after review feedback. glfwInit() used to be called at the top of initWindow() with its return value discarded in every chapter, so a failed initialization only showed up later as a null window. Putting it here gives the guard both ends of the lifetime and gives the failure somewhere to go: the constructor runs during member initialization, and every chapter already builds the application object inside the try in main(), so it is reported like any other std::runtime_error. If it throws, ~GlfwGuard() never runs, so glfwTerminate() is correctly skipped after a failed init.

A destructor body would not work here - it runs before member destruction, so the ordering bug would remain.

Scope

  • 35 chapter sources that own Vulkan objects
  • 00_base_code.cpp, which cannot crash today but is where the pattern is introduced. Including it means the pattern is correct from the first chapter and never has to change - which is what 00_Base_code.adoc already promises the reader ("this is the last time we'll have to do anything in the cleanup() function")
  • en/03_Drawing_a_triangle/00_Setup/00_Base_code.adoc - teaches the guard and explains why the order matters
  • en/03_Drawing_a_triangle/04_Swap_chain_recreation.adoc - snippet no longer terminates GLFW in cleanup()

34_android.cpp is included too, though not for the same reason. An earlier revision of this description claimed it had no GLFW; that was wrong. It has a full PLATFORM_DESKTOP path that calls glfwInit() and glfwCreateWindow(), and it avoided this crash only because it never called glfwTerminate() or glfwDestroyWindow() at all - it leaked GLFW instead of destroying it out of order. It now uses the same guard, so every chapter that owns a window is consistent.

Test environment

OS / kernelArch Linux, 7.1.5-arch1-2
SessionWayland, KDE Plasma / KWin 6.7.3
wayland-client1.25.0
GPUNVIDIA GeForce RTX 5080
DriverNVIDIA 610.43.03 (nvidia-open-dkms), device apiVersion 1.4.341
Vulkan loader / headers / validation layers1.4.357
GLFW3.5.1 (Wayland backend)
CompilerGCC 16.1.1
BuildCMake 4.4.2, Ninja 1.13.2

Testing: before and after

This was measured as a matched pair, not inferred. A pristine worktree at e8c3ba2 (current main) was configured and built with the same toolchain and run through the same harness on the same machine, then the identical sweep was run against this branch.

How the window was closed. Rather than approximating, the close was driven through the compositor: a KWin script calls closeWindow() on the toplevel, which sends a real xdg_toplevel close - identical to clicking the X button. The tested path is the one users actually hit.

Crashes were detected two independent ways: the process exit code, and systemd-coredump records.

Results

TestBefore (main @ e8c3ba2)After (this branch)
All build targets compile304/304304/304
22 chapters closed via real compositor close request22/22 crashed, exit 139 (SIGSEGV)22/22 clean, exit 0
14_command_buffers, 3 consecutive runs3/3 SIGSEGV3/3 exit 0
30_multisampling, 5 consecutive runs5/5 SIGSEGV5/5 exit 0
Validation layer errorsnonenone
Coredumps producedone per crashing runnone

Zero chapters survived close before the change; all 22 exit cleanly after it.

The 22 chapters closed and verified end to end:

15_hello_triangle 16_frames_in_flight 17_swap_chain_recreation
19_vertex_buffer 20_staging_buffer 21_index_buffer
22_descriptor_layout 23_descriptor_sets 24_texture_image
25_sampler 26_texture_mapping 27_depth_buffering
28_model_loading 29_mipmapping 30_multisampling
31_compute_shader 32_ecosystem_utilities 33_vulkan_profiles
35_gltf_ktx 36_multiple_objects 37_multithreading
38_ray_tracing

Chapters before 15_hello_triangle never call presentKHR. On Wayland a surface is not mapped until a buffer is attached, so the compositor has no window to close and they cannot be tested this way. 14_command_buffers was therefore verified by breaking out of the main loop instead, which reaches the identical cleanup() + destructor path - it crashed 3/3 before and exited cleanly 3/3 after, confirming the fix applies to that group too.

Isolating the cause. As a control, removing onlyglfwTerminate() from the unmodified code - a single-variable change that leaks the connection instead of freeing it early - also stopped the crash (5/5 clean). That confirms the destruction order, and nothing else, was responsible. The shipped fix restores correct teardown rather than leaking.

The 30_multisampling and 14_command_buffers run-repeats above were driven by breaking out of the main loop after a fixed frame count, which reaches the same cleanup() + destructor path and allows repeated unattended runs.

Formatting. Only the added lines were formatted. Running the repository's own CI check locally (clang-format-diff.py over the diff against main) reports no violations. Pre-existing formatting violations elsewhere in these files were deliberately left untouched to keep the diff reviewable.

Windows

Windows was measured separately, on Windows 11 / MSVC 14.51 / Vulkan 1.4.357 / RTX 5080. Every chapter was built twice, once from a pristine main at e8c3ba2 and once from this branch, then launched and closed with a real WM_CLOSE - the message the X button sends - with the process exit code recorded.

TestBefore (main @ e8c3ba2)After (this branch)
All build targets compile144/144144/144
36 chapters closed via WM_CLOSE36/36 exit 036/36 exit 0
Crash / non-zero exitnonenone

So Windows does not fault on the original ordering, and this change does not regress it. That is a no-op result rather than a fix on that platform, which matches @asuessenbach's report. Only 22_descriptor_layout produced validation output, in both the before and after sweeps, which is a pre-existing issue on main unrelated to this PR.

Not covered

The same wrong ordering exists on every platform, but whether it faults depends on the WSI teardown path. Testing on X11, macOS/MoltenVK, and AMD/Intel drivers would still be welcome.

Notes

An alternative would be introducing the guard in chapter 01 rather than 00, keeping chapter 0 minimal at the cost of the reader editing cleanup() mid-tutorial. Happy to switch if you prefer that.

Prepared with AI assistance (Claude), reviewed and verified by me on the hardware and software listed above.

`cleanup()` called `glfwDestroyWindow()` and `glfwTerminate()` while every
`vk::raii` member was still alive. Those members belong to the application
class, so they are destroyed only after `run()` returns - that is, after GLFW
has already torn down the window system connection they still reference.
`vkDestroySwapchainKHR` then marshals a request on freed Wayland proxies and
the process dies during exit:
KhronosGroup#12 HelloTriangleApplication::~HelloTriangleApplication()
KhronosGroup#11 vk::raii::SwapchainKHR::~SwapchainKHR()
KhronosGroup#10 vk::raii::SwapchainKHR::clear()
KhronosGroup#4 libnvidia-glcore.so
KhronosGroup#3 wl_proxy_marshal_flags
#0 libwayland-client.so.0 <- SEGV_MAPERR
The pre-RAII tutorial destroyed every Vulkan object by hand at the end of
`cleanup()`, so `glfwTerminate()` genuinely ran last. Converting to `vk::raii`
removed those explicit calls but left the two GLFW calls behind, silently
inverting the order.
Ownership of the GLFW lifetime now belongs to a `GlfwGuard` member declared
first in the class. Members are destroyed in reverse declaration order, so
being first means it is destroyed last - after every Vulkan object, however
many later chapters add. `glfwTerminate()` also destroys any windows still
open, so the separate `glfwDestroyWindow()` call is no longer needed.
Applied to all 34 affected chapters, plus 00_base_code so the pattern is
correct from the very first chapter and never has to change again - which is
what the Base Code chapter already promises the reader.
Verified on Arch Linux / Wayland / KWin, NVIDIA 610.43.03, Vulkan 1.4.357:
- all 304 build targets compile
- 22 chapters that present a frame: launched and closed with a real
compositor close request, all exit 0 (each exited 139 before)
- 14_command_buffers (never presents, so its surface is never mapped and
it cannot be closed by the compositor) checked by breaking out of the
main loop instead: 3/3 SIGSEGV before, 3/3 clean after
- no validation layer errors, and no coredumps from any fixed binary
Refs: KhronosGroup#138
@asuessenbach

asuessenbach commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

On windows, I don't have any issues with window closing, here.
But your approach is working as well, and it clearly is the better one.

But shouldn't the GlfwGuard have a constructor like this:

GlfwGuard()
{
if (!glfwInit())
throw std::runtime_error("Failed to initialize GLFW");
}

And even though implicit destruction of the GLFWwindow works fine, maybe should then introduce some wrapper for it as well, like

	struct GLFWWindowWrapper
{
GLFWWindowWrapper() = delete;
GLFWWindowWrapper(int width, int height, const char *title)
{
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE);
window = glfwCreateWindow(width, height, title, nullptr, nullptr);
}
~GLFWWindowWrapper()
{
if (window)
glfwDestroyWindow(window);
}
GLFWwindow *get() const
{
return window;
}
private:
GLFWwindow *window = nullptr;
};

Or, as already mentioned in #138, switch to vkfw?

Besides that, I would not keep an empty cleanup function.

ozanyasindogan added a commit to ozanyasindogan/Vulkan-Tutorial that referenced this pull request Aug 10, 2026
Follow-up to review feedback on KhronosGroup#443.
`glfwInit()` was called at the top of `initWindow()` and its return value
discarded in every chapter, so a failed initialization surfaced later as a
null window rather than as an error. Moving it into `GlfwGuard` gives the
guard both ends of the GLFW lifetime and gives the failure somewhere to go:
GlfwGuard()
{
if (!glfwInit())
{
throw std::runtime_error("failed to initialize GLFW!");
}
}
The constructor runs during member initialization, before `run()` is entered,
and every chapter already constructs the application object inside the `try`
in `main()`, so the error is reported through the same path as every other
`std::runtime_error`. If it throws, `~GlfwGuard()` never runs, so
`glfwTerminate()` is correctly skipped after a failed init.
Also in this change:
- 34_android.cpp gains the same guard. Its PLATFORM_DESKTOP path creates a
GLFW window but never called `glfwTerminate()` or `glfwDestroyWindow()`,
so it leaked GLFW rather than destroying it out of order. Every chapter
that owns a window now uses the guard.
- `cleanup()` was left as bare empty braces in 31_compute_shader,
32_ecosystem_utilities, 35_gltf_ktx and 38_ray_tracing, while 28 other
chapters carried a comment explaining where GLFW teardown had moved to.
Those four now match.
- The guard in 32_ecosystem_utilities moved above `AppInfo appInfo` so that
"declared first" is literally true there.
- 00_Base_code.adoc introduces the guard where GLFW initialization is
taught, instead of reintroducing it in the cleanup section, and
04_Swap_chain_recreation.adoc drops `glfwInit()` from its initWindow
snippet.
Verified on Windows 11, MSVC 14.51, Vulkan 1.4.357, RTX 5080:
- all 144 build targets compile with no new warnings
- clang-format-diff.py over the diff against main reports no violations
- every chapter built from a pristine main and from this branch, then
closed with a real WM_CLOSE and its exit code recorded: 36/36 exit 0 on
both sides. Windows does not fault on the original ordering, so this
measures no regression rather than a fix on that platform.
Refs: KhronosGroup#138
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ozanyasindogan

Copy link
Copy Markdown
Author

Hi,

I compiled and ran the tutorial files on a Linux/Wayland desktop and the crash happens on every desktop chapter there. On Windows it quits without crashing, like you say. But the ordering still looks wrong to me on both, it just doesn't blow up on Win32.

I checked Windows here as well before answering, so it's not only guesswork. I built every chapter from a clean main and from this branch, ran them all and closed each window the normal way. Nothing crashed on either side and I couldn't see any regression from the change.

I'm still learning Vulkan, so I don't want to overstate the Wayland part. What I could follow from the backtrace is that glfwTerminate() shuts down the connection to the compositor, and the swapchain is destroyed after that while it still needs it. Whether that actually faults seems to depend on the platform. The order itself being wrong is the part I'm confident about, since that one is just member destruction order.

What I wanted with this PR was to fix that without pulling in a new dependency or rewriting too much of the tutorial code.

About glfwInit() in the constructor, you're right and I've added it. The return value was being ignored everywhere anyway:

structGlfwGuard
{
GlfwGuard()
{
if (!glfwInit())
{
throwstd::runtime_error("failed to initialize GLFW!");
}
}
~GlfwGuard()
{
glfwTerminate();
}
} glfwGuard;

initWindow() loses its glfwInit() line. The throw happens while the object is still being constructed, and every chapter already creates app inside a try in main(), so it gets reported like any other runtime_error. And if the constructor throws then the destructor doesn't run, so glfwTerminate() isn't called after a failed init.

The GLFWWindowWrapper is nicer than a raw handle, no argument there, and the ordering in your sketch looks right to me. My hesitation is just how much it pulls in. GLFWWindowWrapper() = delete; makes it non-default-constructible, so it has to be built in a member-init list, and that moves window creation out of initWindow() and into a constructor in every chapter. initWindow() also sets the user pointer and the framebuffer callback, which both need this, so the setup would end up split in two places. The window hints aren't the same everywhere either, many chapters disable resizing, many enable it, and at least one sets no hint at all, so those would have to be passed in somehow. initWindow() is walked through step by step in 00_Base_code.adoc too, so that text would have to change with it.

None of that is an objection in principle. It just makes this a refactor of the tutorial's window setup rather than a crash fix, and it doesn't change teardown behaviour, since glfwTerminate() already destroys any windows still open.

Same for vkfw, only more so, since that is a new dependency in every chapter plus all the prose around it. I'd have thought that one belongs in #138 rather than here.

I also ran a code review with Claude Code over the rest of the chapters. Its notes are below.


Claude Code review notes

34_android.cpp was leaking GLFW. Fixed in this push. The PR description states the file was left untouched because it contains no GLFW. That is incorrect: it carries a full PLATFORM_DESKTOP path that calls glfwInit() and glfwCreateWindow(). It avoided this crash for an unrelated reason, namely that it never called glfwTerminate() or glfwDestroyWindow() at all, so it leaked the library rather than destroying it out of order. It now carries the same guard, which brings every GLFW-owning chapter into line. The description text will be corrected.

Inconsistent cleanup() bodies. Fixed in this push. The previous push left cleanup() as bare empty braces in 31_compute_shader.cpp, 32_ecosystem_utilities.cpp, 35_gltf_ktx.cpp and 38_ray_tracing.cpp, against 28 chapters that carried a comment explaining where GLFW teardown had moved to. Those four now match.

22_descriptor_layout emits validation errors when run. Pre-existing on main, reproducible without this branch, and outside the scope of this fix. Not addressed here. It can be raised separately if that is useful.

Verification. All 144 targets build clean under MSVC 14.51 with no new warnings. The repository's own clang-format-diff.py check over the diff against main reports no violations. Both a pristine main build and this branch were swept on Windows by issuing a real close request to each chapter window and recording process exit codes, with no crashes and no behavioural difference between the two.

@ozanyasindogan

Copy link
Copy Markdown
Author

The Android Build failure looks unrelated to this PR. It's a Gradle/AGP incompatibility in CI rather than anything in the tutorial code.

The job fails while applying the Android Gradle plugin, before any C++ gets compiled:

Plugin 'com.android.internal.application' relies on 'org.gradle.api.problems.internal.InternalProblems',
a Gradle internal API that was removed in Gradle 9.6.0.
Update the plugin to a version that no longer uses Gradle internal APIs, or use Gradle 9.5.

attachments/android/ has no checked-in gradlew, so the workflow runs gradle wrapper (workflow.yml L572) with whatever Gradle the runner image ships, currently 9.6.1. attachments/android/build.gradle pins AGP 8.11.0, which relies on the API that was removed in 9.6.0. There is a gradle/wrapper/gradle-wrapper.properties pinning gradle-9.0-milestone-1, but with no gradlew present it gets regenerated and never takes effect.

This PR seems to be the first one to actually run the job. Android Build is gated on check-android-changes, whose ANDROID_PATTERN includes attachments/35_gltf_ktx.cpp (workflow.yml L72), which this PR touches. Across the last 25 CMake CI runs the job is skipped everywhere else, including every push to main, so the breakage has been sitting there since the runner image moved to Gradle 9.6.x.

For completeness, the change can't affect the Android build. In all three Android-supported chapters it touches (34_android.cpp, 35_gltf_ktx.cpp, 36_multiple_objects.cpp) the guard sits inside the #else of #if PLATFORM_ANDROID, and the glfwInit() move is inside #if PLATFORM_DESKTOP, so none of it is compiled for Android.

Two ways to get the job green again, either as a one-liner here or as a separate PR:

  • pin the wrapper: gradle wrapper --gradle-version 9.5
  • or bump AGP to a version that supports Gradle 9.6

Follow-up to review feedback on KhronosGroup#443.
`glfwInit()` was called at the top of `initWindow()` and its return value
discarded in every chapter, so a failed initialization surfaced later as a
null window rather than as an error. Moving it into `GlfwGuard` gives the
guard both ends of the GLFW lifetime and gives the failure somewhere to go:
GlfwGuard()
{
if (!glfwInit())
{
throw std::runtime_error("failed to initialize GLFW!");
}
}
The constructor runs during member initialization, before `run()` is entered,
and every chapter already constructs the application object inside the `try`
in `main()`, so the error is reported through the same path as every other
`std::runtime_error`. If it throws, `~GlfwGuard()` never runs, so
`glfwTerminate()` is correctly skipped after a failed init.
Also in this change:
- 34_android.cpp gains the same guard. Its PLATFORM_DESKTOP path creates a
GLFW window but never called `glfwTerminate()` or `glfwDestroyWindow()`,
so it leaked GLFW rather than destroying it out of order. Every chapter
that owns a window now uses the guard.
- `cleanup()` was left as bare empty braces in 31_compute_shader,
32_ecosystem_utilities, 35_gltf_ktx and 38_ray_tracing, while 28 other
chapters carried a comment explaining where GLFW teardown had moved to.
Those four now match.
- The guard in 32_ecosystem_utilities moved above `AppInfo appInfo` so that
"declared first" is literally true there.
- 00_Base_code.adoc introduces the guard where GLFW initialization is
taught, instead of reintroducing it in the cleanup section, and
04_Swap_chain_recreation.adoc drops `glfwInit()` from its initWindow
snippet.
Verified on Windows 11, MSVC 14.51, Vulkan 1.4.357, RTX 5080:
- all 144 build targets compile with no new warnings
- clang-format-diff.py over the diff against main reports no violations
- every chapter built from a pristine main and from this branch, then
closed with a real WM_CLOSE and its exit code recorded: 36/36 exit 0 on
both sides. Windows does not fault on the original ordering, so this
measures no regression rather than a fix on that platform.
Refs: KhronosGroup#138
@ozanyasindogan
ozanyasindoganforce-pushed the fix/glfw-vulkan-destruction-order branch from 207324c to 8d7abf5CompareAugust 10, 2026 13:35
@SaschaWillems

Copy link
Copy Markdown
Collaborator

We are aware of the Android issue, it also occurs in one of our own PRs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@ozanyasindogan@asuessenbach@SaschaWillems