Skip to content

libghostty: ghostty_surface_free deadlocks the app thread when the surface's reader is blocked pushing into the app mailbox #14245

Description

@jcollie

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() (
    self.io_thr.join();
    )
  • io thread: termio.Thread.threadMain -> Exec.threadExit -> exec.read_thread.join() (
    exec.read_thread.join();
    )
  • io-reader (parse stage): ReadThread.threadMainPosix -> Termio.processOutput -> a stream handler action -> surfaceMessageWriter (
    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.push waiting on cond_not_full (
    self.cond_not_full.waitUncancelable(io, &self.mutex);
    )
  • the queue is BlockingQueue(Message, 64) (
    pub const Queue = BlockingQueue(Message, 64);
    ) and its only consumer is App.drainMailbox from App.tick (
    fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
    ), on the app thread

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 (

ghostty/src/termio/Exec.zig

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);
}
) loops on 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.

  1. Create a surface and run in it (zsh): while :; do printf '\e]0;t%s\a' $RANDOM; done
  2. Close that surface from the app thread.
  3. The close never returns. sample <pid> shows the app thread in Surface.deinit -> pthread_join, the surface's io thread in the killPid loop (single surface) or in pthread_join on its reader (eight spamming surfaces, one closed), and its io-reader in apprt.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 = paste

Additional 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

  1. jcollie commented on Sep 15, 2026

    @jcollie
    MemberAuthor

    I can reproduce on Linux/GTK with this script:

    #!/bin/sh
    for i in 1 2 3 4; do
      setsid timeout 60 bash -c 'while :; do printf "\033]0;t$RANDOM\007"; done' &
    done
    sleep 3
    

    run as ghostty -e ./spam.sh and Ghostty will hang here:

    debug(app): mailbox message=surface_message
    debug(surface): changing title "t22020"
    debug(gtk_ghostty_application): must_quit due to no app windows
    debug(gtk_ghostty_application): exiting runloop
    debug(glib): DEBUG: dconf: sync
    debug(renderer_thread): mailbox message=.{ .focus = false }
    debug(io_thread): mailbox message=focused
    debug(renderer_thread): starting renderer thread shutdown
    debug(renderer_thread): renderer thread exited
    debug(io_thread): starting IO thread shutdown
    
  2. added theissue type on Sep 15, 2026
  3. added this to the 1.4.0 milestone on Sep 15, 2026
  4. AKhozya commented on Sep 28, 2026

    @AKhozya

    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)

  5. jakob1379 commented on Sep 29, 2026

    @jakob1379

    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:

    • main thread: pthread_join → waiting on a surface's io thread
    • that io thread: pthread_join → waiting on its io-reader
    • io-reader: futex wait inside ghostty code (binary is stripped, so the frame is unsymbolized, offset 0x1539c3a)

    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)
    Thread 3 (LWP 1125098) "io":
    #3  __pthread_clockjoin_ex () from libc.so.6
    #4  0x0000569f892ba46d in ?? ()   // ghostty+0x144c46d
    
    Thread 2 (LWP 1125100) "io-reader":
    #0  0x0000569f893a7c3a in ?? ()   // ghostty+0x1539c3a (futex)
    
    Thread 1 (LWP 1093803) main:
    #3  __pthread_clockjoin_ex () from libc.so.6
    #4  0x0000569f891d64a8 in ?? ()   // ghostty+0x13684a8
    

    Kernel stacks for all three: futex_do_wait ← __futex_wait ← futex_wait ← do_futex.

  6. added 4 commits that reference this issue on Sep 30, 2026
    ce2d7e2
    6c9a9fb
    7720f9b
    83ac26c
  7. added 4 commits that reference this issue on Oct 6, 2026
    5561b16
    c20fb23
    746e053
    acc6061
  8. 7 remaining items

  9. added 2 commits that reference this issue on Oct 26, 2026
    c094831
    eaf899d
  10. modified the milestones: 1.4.0, 1.5.0 on Oct 27, 2026
  11. added 4 commits that reference this issue on Oct 27, 2026
    71da7c0
    921e1f7
    cceab5a
    c75b793
  12. added a commit that references this issue on Nov 7, 2026
    efaa666
  13. jrosskopf commented on Nov 7, 2026

    @jrosskopf

    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 = true via the stock .desktop file, 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-*.scope stopped), and right after that every window stopped redrawing permanently.

    What I saw (stripped binary, so only partly symbolized):

    • The main thread was parked on a futex with zero context switches over several seconds. It was inside the GTK teardown path: gtk_widget_unrealize (nested signal emissions) -> gtk_accessible_update_state -> ghostty code -> futex wait. This fits Surface.deinit -> io_thr.join().
    • The io thread of the closed surface was still alive.
    • Several io-reader threads of other surfaces were parked on the same futex wait, right above a write() frame. This fits them blocking on App.Mailbox.push once the app thread stopped draining it.
    • All renderer threads were idle on futexes. The io/io-gather threads of live surfaces kept reading PTY output.

    That matches the chain in this issue. Recovery was kill -9. As a stopgap I switched to gtk-single-instance = false so a hang only takes down one window. Happy to test #14444 if a build becomes available.

  14. mitchellh commented on Nov 7, 2026

    @mitchellh
    Contributor

    libghostty embedders.

    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/)

  15. added a commit that references this issue on Nov 9, 2026
    2bc653a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions