Description
Discussed in #14234
Originally posted by umputun September 14, 2026
Issue Description
Freeing a surface can deadlock the app thread forever. Surface.deinit joins the io thread on the calling (app) thread, the io thread joins its reader thread, and the reader can be blocked in a .forever push into the app mailbox, a queue only the app thread drains. Once the app thread is inside the join nothing drains the queue, so the reader never returns, the io thread never returns, and ghostty_surface_free never returns. Every other surface whose reader then posts a surface message parks in the same push.
The wait chain at 683d8db (unchanged on d5eba8d):
- app thread:
ghostty_surface_free->Surface.deinit->self.io_thr.join()()Line 818 in 683d8db
self.io_thr.join(); - io thread:
termio.Thread.threadMain->Exec.threadExit->exec.read_thread.join()()Line 229 in 683d8db
exec.read_thread.join(); - io-reader (parse stage):
ReadThread.threadMainPosix->Termio.processOutput-> a stream handler action ->surfaceMessageWriter() ->ghostty/src/termio/stream_handler.zig
Lines 130 to 140 in 683d8db
inline fn surfaceMessageWriter( self: *StreamHandler, msg: apprt.surface.Message, ) void { // See messageWriter which has similar logic and explains why // we may have to do this. if (self.surface_mailbox.push(msg, .{ .instant = {} }) == 0) { self.renderer_state.mutex.unlock(global.io()); defer self.renderer_state.mutex.lockUncancelable(global.io()); _ = self.surface_mailbox.push(msg, .{ .forever = {} }); } apprt.surface.Mailbox.push->App.Mailbox.push->BlockingQueue.pushwaiting oncond_not_full()ghostty/src/datastruct/blocking_queue.zig
Line 115 in 683d8db
self.cond_not_full.waitUncancelable(io, &self.mutex); - the queue is
BlockingQueue(Message, 64)() and its only consumer isLine 611 in 683d8db
pub const Queue = BlockingQueue(Message, 64); App.drainMailboxfromApp.tick(), on the app threadLine 265 in 683d8db
fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
In the original user's sample the blocked reader came through a CSI action (Stream.csiDispatchFinal -> StreamHandler.vt -> Mailbox.push) and four other surfaces' readers were parked in the same push from StreamHandler.windowTitle. In my reproductions the closing surface's reader is in windowTitle -> Mailbox.push.
A second shape of the same hang, seen in every single-surface run: the reader stall fills the 4-slot gather ring, the gather stage stops reading the pty, and Exec.threadExit never reaches the quit-pipe write because Subprocess.stop -> killPid (
Lines 1176 to 1214 in 683d8db
| fn killPid(pid: c.pid_t) !void { | |
| const pgid = getpgid(pid) orelse return; | |
| // It is possible to send a killpg between the time that | |
| // our child process calls setsid but before or simultaneous | |
| // to calling execve. In this case, the direct child dies | |
| // but grandchildren survive. To work around this, we loop | |
| // and repeatedly kill the process group until all | |
| // descendents are well and truly dead. We will not rest | |
| // until the entire family tree is obliterated. | |
| while (true) { | |
| switch (posix.errno(c.killpg(pgid, c.SIGHUP))) { | |
| .SUCCESS => log.debug("process group killed pgid={}", .{pgid}), | |
| else => |err| killpg: { | |
| if ((comptime builtin.target.os.tag.isDarwin()) and | |
| err == .PERM) | |
| { | |
| log.debug("killpg failed with EPERM, expected on Darwin and ignoring", .{}); | |
| break :killpg; | |
| } | |
| log.warn("error killing process group pgid={} err={}", .{ pgid, err }); | |
| return error.KillFailed; | |
| }, | |
| } | |
| // See Command.zig wait for why we specify WNOHANG. | |
| // The gist is that it lets us detect when children | |
| // are still alive without blocking so that we can | |
| // kill them again. | |
| const res_pid = while (true) { | |
| const rc = posix.system.waitpid(pid, null, std.c.W.NOHANG); | |
| if (posix.errno(rc) == .INTR) continue; | |
| break rc; | |
| }; | |
| log.debug("waitpid result={}", .{res_pid}); | |
| if (res_pid != 0) break; | |
| try std.Io.sleep(global.io(), .fromMilliseconds(10), .awake); | |
| } |
killpg / waitpid(WNOHANG) / 10 ms sleep with no bound. ps shows the child's login wrapper in exit state with its own child a zombie or also exiting, and waitpid never returns it. I read that as the wrapper's exit blocking on tty output drain; the sample proves the loop and the full ring, not the kernel wait.
This is an embedding report: libghostty built from 683d8db, embedded in agterm (macOS, SwiftUI; https://github.com/umputun/agterm). A user hit it with 10 surfaces open (umputun/agterm#606), and it reproduces on demand with the recipe below, in the shipped agterm 0.28.0 and in a Debug build using the same pinned libghostty. Ghostty.app 1.3.1 and the tip build (d5eba8d) did not hang when the spamming command exited on its own and the surface closed on child exit; closing a still-producing Ghostty.app surface was not tested.
Expected Behavior
ghostty_surface_free returns. A surface whose program is producing output at the moment of the close is torn down like any other; at worst its pending output is dropped.
Actual Behavior
ghostty_surface_free blocks forever in pthread_join. The app thread is gone, so every surface in the process stops updating and the process has to be killed with SIGKILL. In the io-join shape the closed surface's child was already reaped; in the kill-loop shape its login wrapper is stuck in exit with a zombie or exiting child.
Reproduction Steps
Tested host: agterm 0.28.0 (and a Debug build using the same pinned libghostty) on macOS 26.6.2, libghostty 683d8db. The close is agterm's agtermctl session close, which calls ghostty_surface_free on the app thread.
- Create a surface and run in it (zsh):
while :; do printf '\e]0;t%s\a' $RANDOM; done - Close that surface from the app thread.
- The close never returns.
sample <pid>shows the app thread inSurface.deinit->pthread_join, the surface'siothread in thekillPidloop (single surface) or inpthread_joinon its reader (eight spamming surfaces, one closed), and itsio-readerinapprt.surface.Mailbox.push->__ulock_wait2.
Control runs: on the 0.28.0 instance, with the spamming surface open and no close, the app thread answered 10 control requests over 10 seconds; on the Debug instance, closing one idle surface with eight idle surfaces open took 67 ms.
Raw sample outputs (Zig frames stripped) and the symbolication table resolving them against libghostty-internal.a: https://gist.github.com/umputun/9ffd6da3e3b93d649ae9c109c59823d4
Ghostty Logs
2026-09-14 13:21:17.565 E agterm[69525:1d9e6a0] [com.mitchellh.ghostty:default] io_exec: shell could not be detected, no automatic shell integration will be injected
That is the only com.mitchellh.ghostty line during the 0.28.0 run; nothing is logged at the hang.
Ghostty Version
libghostty from ghostty-org/ghostty 683d8db643b95cf229bfb5fe9fab9ae677920343 (2026-08-25)
zig build -Doptimize=ReleaseFast -Demit-xcframework=true -Dxcframework-target=native, zig 0.16.0
embedded in agterm 0.28.0 (https://github.com/umputun/agterm)
the cited code is unchanged on main at d5eba8d16 (Ghostty 1.3.2-main-+d5eba8d16)
OS Version Information
macOS 26.6.2 (25G83), Apple Silicon
Minimal Ghostty Configuration
The config agterm passes to libghostty in the reproduction (theme = agterm is a theme file agterm bundles; the other two are unrelated to the hang):
theme = agterm
mouse-scroll-multiplier = 3
right-click-action = pasteAdditional Relevant Configuration
Downstream report with the user's samples: umputun/agterm#606
AI usage disclosure: Claude Code and Codex, driven by me, did the offset symbolication (instruction-sequence matching against libghostty-internal.a), ran the reproductions and wrote the first draft of this text. I reviewed the cited code paths and edited the text.
I acknowledge that:
- I have reviewed the FAQ and confirm that my issue is NOT among them.
- I have searched the Ghostty documentation and confirm there doesn't exist a configuration option that solves my issue.
- I have searched the Ghostty repository (both open and closed Discussions and Issues) and confirm this is not a duplicate of an existing issue or discussion.
- I have checked the "Preview" tab on all text fields to ensure that everything looks right, and have wrapped all configuration and code in code blocks with a group of three backticks (
```) on separate lines.
Activity
jcollie commentedon Sep 15, 2026
I can reproduce on Linux/GTK with this script:
run as
ghostty -e ./spam.shand Ghostty will hang here:AKhozya commentedon Sep 28, 2026
Happens quite often for me during switch to ghostty from somewhere or on attaching external monitor to mac. Symptoms - 1st active tab hangs, on switch to other tab it hangs and then whole ghostty hangs. MacOS 27.0 latest build from tip (28 Sep one)
jakob1379 commentedon Sep 29, 2026
Another reproduction, different environment: Linux/Wayland on niri, NixOS, kernel 7.2.6, ghostty 1.3.1 (nixpkgs), GTK single-instance (
--gtk-single-instance=true --initial-window=false), default io_uring async backend.Whole app (all windows) froze. All threads at 0% CPU. Same join chain as the original report:
pthread_join→ waiting on a surface'siothreadiothread:pthread_join→ waiting on itsio-readerio-reader: futex wait inside ghostty code (binary is stripped, so the frame is unsymbolized, offset0x1539c3a)The stuck surface's renderer thread and child process were already gone, so it was mid-teardown. Every other surface's io/renderer threads were idle normally in
io_cqring_wait/poll.gdb (relevant threads)
Kernel stacks for all three:
futex_do_wait ← __futex_wait ← futex_wait ← do_futex.surface: keep the app mailbox draining while joining threads
surface: keep the app mailbox draining while joining threads
surface: keep the app mailbox draining while joining threads
surface: keep the app mailbox draining while joining threads
drain a closed pane's mailbox before freeing its surface
surface: keep the app mailbox draining while joining threads
surface: keep the app mailbox draining while joining threads
surface: keep the app mailbox draining while joining threads
surface: keep the app mailbox draining while joining threads
7 remaining items
surface: keep the app mailbox draining while joining threads
surface: keep the app mailbox draining while joining threads
An early close of a pane that floods its title waits 5 s (135) (#44)
surface: keep the app mailbox draining while joining threads
surface: keep the app mailbox draining while joining threads
surface: keep mailboxes draining while joining threads
surface: keep mailboxes draining while joining threads
docs(ghostty): record the libghostty draw-mutex deadlock as a known i…
Stop closing a busy terminal from freezing the app (#335)
surface: keep mailboxes draining while joining threads
jrosskopf commentedon Nov 7, 2026
Another data point: this still happens on 1.4.0 (Arch package
1.4.0-arch1, tip channel) with the GTK apprt, so it isn't limited to 1.3.1 or libghostty embedders.Environment: Arch Linux, kernel 7.2.14, niri (Wayland), GTK 4, OpenGL renderer, AMD RX 9070 XT with Mesa 26.2.6 (radeonsi), default io_uring backend.
gtk-single-instance = truevia the stock.desktopfile, so one process hosts every window. The machine was under heavy memory pressure at the time (about 42 GiB of swap in use).Trigger: the process had been running for about 23h. A long-lived surface closed (its
app-ghostty-surface-transient-*.scopestopped), and right after that every window stopped redrawing permanently.What I saw (stripped binary, so only partly symbolized):
gtk_widget_unrealize(nested signal emissions) ->gtk_accessible_update_state-> ghostty code -> futex wait. This fitsSurface.deinit->io_thr.join().iothread of the closed surface was still alive.io-readerthreads of other surfaces were parked on the same futex wait, right above awrite()frame. This fits them blocking onApp.Mailbox.pushonce the app thread stopped draining it.io/io-gatherthreads of live surfaces kept reading PTY output.That matches the chain in this issue. Recovery was
kill -9. As a stopgap I switched togtk-single-instance = falseso a hang only takes down one window. Happy to test #14444 if a build becomes available.mitchellh commentedon Nov 7, 2026
Just to clarify: this API is not part of libghostty, in case you or someone else finds this and thinks it is. This is part of our internal API surface that libghostty doesn't touch at all (see https://lib.ghostty.org/vt/)
surface: keep mailboxes draining while joining threads