Skip to content

GTK: audio bell leaks a GStreamer pipeline + GL-context threads per ring (idle CPU climbs over time) #13647

Closed

Description

@jcollie

Discussed in #12808

Originally posted by nikicat May 25, 2026

Issue Description

With bell-features = audio and an (optional) bell-audio-path set, every terminal bell leaks a GStreamer pipeline and its GL-context threads. They are never torn down, so over a long-running session CPU usage climbs steadily and thread count grows without bound.

After ~1.5 days of normal use (multiple long-lived TUI sessions that ring the bell on completion), a single Ghostty process had accumulated 3,482 threads and was consuming ~3 CPU cores while idle. Thread-name histogram from /proc/<pid>/task/*/comm:

1358 gldisplay-event
 696 ghostty:gdrv
 696 ghostt:traceq
 679 gstglcontext
  14 renderer / io / io-reader (normal)

That is ~696 GStreamer playbin/GL pipelines that were never freed — almost exactly one per bell rung. The leaked threads are parked in __futex_wait (0 nonvoluntary ctx switches), but the ~1358 GL display event loops still burn multiple cores.

Root cause (from source)

ringBell allocates a fresh MediaFile on every bell:

  • src/apprt/gtk/class/surface.zig:2489 → media.fromFilename(path) → gtk.MediaFile.newForFilename(path)
  • On Linux the default GTK media backend is GStreamer, and libgstgtk4 wires the playback through a GL sink — so even an audio-only .oga spins up a gstglcontext + gldisplay-event thread set per pipeline.

Cleanup relies solely on notify::ended → unref (src/apprt/gtk/media.zig:96-102). Critically, the error handler is connected only when required == true (src/apprt/gtk/media.zig:50-58):

if (required) {
    _ = gobject.Object.signals.notify.connect(media_file, ..., .{ .detail = "error" });
}
// "ended" handler always connected; calls media_file.unref()

So for an optional bell-audio-path (the common case), any MediaFile that errors or never reaches the ended state is never unref'd, and its pipeline + GL threads leak. The ~696 leaked sets ≈ the number of bells fits this. (A happy-path teardown leak in the GStreamer GL backend is also possible and would compound it — see "Open question".)

The 100 ms per-surface BEL rate-limit (#9810, present since 1.3.0) caps the acute DoS freeze but does nothing for this slow per-bell leak, since legitimate bells fire far below 10/s.

This is the same gtk_media_file_new_for_filename-per-bell path implicated in the crash in #11720 (missing playbin3), and any teardown/unref fix should be checked against the GTK ownership double-free discussed in #12306.

Expected Behavior

Ringing the audio bell N times leaves the thread/pipeline count bounded (ideally constant), and idle CPU returns to baseline after playback finishes.

Actual Behavior

Each audio bell leaks a GStreamer pipeline and its GL threads. Thread count and idle CPU grow without bound over the lifetime of the process.

Reproduction Steps

  1. Config: bell-features = audio + a valid bell-audio-path.
  2. Spam bells, spaced past the 100 ms rate-limit:
    while true; do printf '\a'; sleep 0.2; done
    
  3. Watch the thread count climb and not recover:
    watch -n1 'ls /proc/$(pidof ghostty)/task | wc -l'
    

Ghostty Logs

No response

Ghostty Version

Ghostty 1.3.1-arch2

(Code path is unchanged on tip — the 79162279d "enabling reuse" refactor only relocated the helpers; the bell call site still allocates a new MediaFile per ring.)

OS Version Information

Arch Linux. GTK 4.22.4, libadwaita 1.9.0, GStreamer 1.28.3 (gst-plugins-base/good 1.28.3). Default (GStreamer) GTK media backend (GTK_MEDIA unset); libgstgtk4.so present.

(Linux only) Display Server

Wayland

(Linux only) Desktop Environment/Window Manager

No response

Minimal Ghostty Configuration

bell-features = audio,attention,title,border
bell-audio-path = /path/to/bell.oga

Additional Relevant Configuration

Triggered in practice by long-lived TUI programs that ring the bell on each completion (e.g. AI coding agents), accumulating hundreds of bells over a multi-day session.

Suggested fixes

  • Reuse a single MediaFile per surface (seek(0) + play() on each bell) to bound pipelines to 1 per surface regardless of root cause. Note: the Audible ping not working as expected #8957 "plays only once" bug was reuse without seeking back to start, so seek(0) is the key detail.
  • And/or connect the error handler unconditionally so the optional-audio path also tears down.
  • Longer term, consider a non-GStreamer bell (e.g. libcanberra / direct PipeWire) to avoid the GL pipeline entirely.

Open question for maintainers

Does the leak occur on the happy path (i.e. does the GStreamer GTK4/GL backend fail to join gstglcontext/gldisplay-event threads even when ended fires and unref runs), or only on the error/never-ended path? The fix scope depends on this. A minimal GTK4 harness that loops new_for_filename → play → await ended → unref while sampling g_type_get_instance_count(gtk_media_file_get_type()) (GOBJECT_DEBUG=instance-count) and /proc/self/task would disambiguate.

Activity

  1. added
    gtkIssues exclusive to GTK which are not OS-specific
    on Aug 5, 2026
  2. added theissue type on Aug 5, 2026
  3. self-assigned this
    on Aug 6, 2026
  4. added 2 commits that reference this issue on Aug 6, 2026
    f0f3f4d
    e1fc390
  5. added this to the 1.4.0 milestone on Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

gtkIssues exclusive to GTK which are not OS-specific

Type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions