Mitchell Hashimoto
We Rewrote the Ghostty GTK Application
We just completed rewriting the Ghostty GTK application fully embracing the GObject type system from Zig and also verifying with Valgrind every step of the way. The result is a more feature rich, stable, and maintainable Ghostty on Linux and BSD.
There are multiple interesting, technical topics from this process, but I want to focus in on two (1) interfacing with the GObject type system from Zig and (2) verifying a GTK application with Valgrind and reflecting on the memory issues Valgrind found in a Zig codebase.
Background
First, some quick background. Ghostty is cross-platform (macOS, Linux, FreeBSD) terminal emulator. Ghostty sets itself apart from other cross-platform terminal emulators by using a platform-native application or GUI framework for each platform1.
On macOS, Ghostty is a multi-thousand line Swift application built with Xcode. On Linux and BSD, Ghostty is a multi-thousand line GTK application leveraging direct integrations with X11, Wayland, etc. Tying it all together, there is a very large shared core written in Zig that exports a C ABI compatible API.
For full motivation on why Ghostty was the way it was before and why we decided to rewrite the GTK application now, see the original "gtk-ng" PR. I'm going to keep this post focused on the takeaways rather than the motivation.
GObject Type System and Zig
Whatever your feelings are about OOP and memory management, the reality is that if you choose GTK, you're forced into interfacing in some way with the GObject type system. You can't avoid it.
Well you can avoid it and we did avoid it. And it leads to a mess trying to tie the lifetimes of your non-reference-counted objects to the reference counted ones. There was an entire class of bug that kept popping up in the Ghostty GTK application that could basically be summed up as: the Zig memory or the GTK memory has been freed, but not both.
Besides the correctness issues, avoiding the object system also forced us away from using GTK-native features such as signals (events), properties (which can be bound to by GUI elements), actions (invoking one-way behavior from afar), and more.
Let's look at a concrete example: reloadable configuration. The
configuration in Ghostty is represented by a Zig-owned Config
structure.
Many different parts of the GUI need to be aware of the configuration:
windows, tabs, menus, splits, etc.
Reloading configuration was a complicated, CPU-intensive (relatively),
and error-prone task because we had to ensure the entire GUI updated
before we could free the old Config
.
Now, the Zig Config
structure is wrapped in a
reference-counted GhosttyConfig
GObject.
When we reload the config, we overwrite our property and let the GObject
property change notification system ripple through the application (sometimes
across multiple event loop ticks). When the old configuration no longer has
any references, it frees. Conceptually much simpler.
In addition to memory management, we can now more easily create custom GTK widgets. This let us fully embrace modern GTK UI technologies such as Blueprint. For example, here is our terminal window Blueprint file. This has already led to more easily introducing GUI features like a new GTK titlebar tabs option, an animated border on bell, etc.
Valgrind with GTK and Zig
This topic deserves an entire blog post on its own. The gist of it is that from the first PR to the last, we've run every change and Ghostty feature through Valgrind and addressed any issues to ensure that there are no memory leaks, undefined memory access, etc.
Running Valgrind on a GTK application is pretty nasty. We need a pretty large suppression file. I know its a lot, but 80% of that file is provided by GTK itself. The remainder is primarily 3rd party libraries and GPU drivers. There are perhaps one or two suppressions I find suspicious (and commented as such).
The important part is we were able to identify a number of bugs along the
way that would've definitely slipped under the radar. For example, I learned
that if you forget to clear a GObject WeakRef
during dispose,
it'll cause undefined memory access in the target (referenced) object when it
disposes at some point in the future (can be hours, days!). That undefined
memory access happens to be fine 99% of the time, but once in awhile it
causes a crash. Fun! Valgrind found this without problem.
Memory safety seems to... erm... activate certain conversations. So let me say two things:
-
Our Zig codebase had one leak and one undefined memory access. That was really surprising to me (in a good way). Our Zig codebase is large, complex, and uses tons of memory tricks for performance that could easily lead to unsafe behaviors. I thought we'd have a lot more issues, honestly. Also, the one leak found was during calling a 3rd party C API (so Zig couldn't detect it). So this is a huge success.
Zig has a leak-detecting debug allocator and various safety checks that are only on during debug and test builds in the Ghostty project. Additionally, Zig has integrations with Valgrind. For example, Zig emits a Valgrind client request to mark some memory as undefined whenever you set a value as
undefined
(keyword) in Zig. This helps find even more issues.This experience has really shown me that this is working, despite not having any of these protections in our release builds.
-
All other memory issues revolved around C API boundaries. Every other issue we found (and there were dozens) was directly within the complex lifetimes of the GObject system or on C API boundaries. My takeaway here is that you absolutely need tooling like Valgrind to safely call across C APIs (even if they're not written in C).
For most complex libraries that expose a C API, the C API represents a boundary where object lifetime is transferred or blurred. Whatever language you're using to interact with it, the safety you're guaranteed is only as good as understanding the semantics of the API and writing a good wrapper.
The features Zig provides around memory safety are well documented. There are a lot of academic or theoretical discussions about what Zig does or does not do and whether that's good or bad. Those are valuable discussions to be had, but so are empirical results. This process is showing empirical results from a large, complex, multi-threaded, multi-platform Zig project when every individual feature is run with scrutiny under Valgrind. Takeaway from what what you want, I don't want to start any flame wars!
Going forward, I plan to continue to run every GTK PR within Valgrind and improving our project documentation so maintainers and contributors can do so as well (we already have a couple up and running!).
Conclusion
This is now my 5th time writing the GUI part of Ghostty from scratch: once with GLFW, once on macOS with SwiftUI, then on macOS with AppKit plus SwiftUI, once on Linux with GTK procedurally, and now on Linux with GTK and the full GObject type system.
Each time, I've learned something new and valuable, and I've carried that experience into each iteration (and across platforms). Even this time, I've learned some new tricks that I plan on taking back over to macOS.
I want to also highlight that the entire GTK subsystem maintenance team hopped on board to help complete the rewrite. They did a lot of work, too.
The new, rewritten Ghostty GTK application is now the default when
you build Ghostty from source on main
, and will be shipped to everyone
in the 1.2 release coming in just a few weeks.
Footnotes
-
Linux people get really worked up when I say "platform-native". There is no such thing on Linux, but reasonable people agree that something like a GTK app (or Qt) feels "native" on most desktops over other applications. ↩