Skip to content

Ghostty: focus-gained handler re-arms mode 1004, causing a 42k writes/s feedback loop in an idle session #1184

Description

@MatCer

Summary

In Ghostty, an idle jcode session burns 66% CPU in the client and 90-106% in Ghostty's renderer thread, purely because the window is focused. Empty session, no prompt typed, nothing streaming.

jcode issues ~42,000 write() syscalls per second, 61 bytes each, 2.5 MB/s into a 114x32 terminal. Unfocus the window and it drops to exactly 0. Refocus and it comes straight back.

Two behaviours form a loop:

  1. jcode calls reapply_configured_terminal_modes() inside its Event::FocusGained handler, which re-issues ESC[?1004h (EnableFocusChange).
  2. Ghostty answers any set of mode 1004 by reporting the current focus state. While focused that reply is CSI I, which crossterm decodes as Event::FocusGained.

jcode's response to a focus event generates another focus event. No timer is involved, which is why no rendering setting affects it.

Suggested labels: bug, priority: high, triage: reproducible, autonomous: clear.

Environment

  • jcode v0.81.7 (358226c), prebuilt Linux x86_64 binary
  • Ghostty 1.3.1
  • Ubuntu, GNOME 46, Wayland (also reproduced on X11)
  • crossterm 0.29.0 (Cargo.lock:1426-1428)
  • Terminal 114x32

Reproduction

Open a Ghostty tab, run jcode, type nothing. Sample the client:

P=<jcode client pid>
W1=$(awk '/^syscw/{print $2}' /proc/$P/io); sleep 5
W2=$(awk '/^syscw/{print $2}' /proc/$P/io); echo $(( (W2-W1)/5 )) writes/s

Sample unfocused, then focused, then unfocused again.

window state write() syscalls/s jcode CPU
not focused 0 0%
focused 42,740 / 42,603 / 42,384 66-67%
not focused again 0 0%

Ghostty threads while focused: renderer 104-106%, io-reader 29%, io 25%. A Ghostty window running sleep 600 sits at 0%, so Ghostty alone does not spin.

Root cause

crates/jcode-tui/src/tui/app/local.rs:397:

Some(Ok(Event::FocusGained)) => {
    crate::tui::reapply_configured_terminal_modes();

That reaches reapply_terminal_modes_to (mod.rs:155-179), which re-issues the mode set with no rate limit, no dedup, and no check that the modes were ever lost:

writer.queue(EnableBracketedPaste)?;
if focus_change { writer.queue(EnableFocusChange)?; }   // ESC[?1004h

Same call at remote.rs:395 and remote.rs:772. The latter is in handle_terminal_event_while_disconnected, so a disconnected client spins too.

Event::FocusLost (local.rs:403) does not reapply. That asymmetry is why the bug is focus-gated.

Ghostty v1.3.1, src/termio/stream_handler.zig:755:

.focus_event => if (enabled) self.messageWriter(.{
    .focused = self.terminal.flags.focused,
}),

Setting mode 1004 emits the current focus state, with no check for whether the mode was already enabled. Re-arming an armed mode still produces a reply. This arrived in Ghostty PR #2196.

The cycle:

FocusGained (local.rs:397)
  -> reapply_configured_terminal_modes()   (local.rs:398)
  -> ESC[?1004h inside a 61-byte payload   (mod.rs:165)
  -> Ghostty replies CSI I                 (stream_handler.zig:755)
  -> crossterm decodes Event::FocusGained
  -> repeat

Unfocused, Ghostty replies CSI O, FocusLost does not reapply, and it stops after one hop.

The payload size matches:

$ printf '\033[?2004h\033[?1004h\033[?1000h\033[?1002h\033[?1003h\033[?1015h\033[?1006h\033[=7u' | wc -c
61

42,740 writes/s x 61 B = 2.6 MB/s, matching the measured 2.5 MB/s.

Reproduction without Ghostty

A pty.fork() harness running the real jcode binary, emulating one Ghostty behaviour and nothing else: on seeing b"\x1b[?1004h" from the slave, write back b"\x1b[I" for focused or b"\x1b[O" for unfocused. Dumb sink otherwise.

Same binary, same environment, 8 second runs, one byte of difference in the reply:

simulated state bytes from jcode ESC[?1004h observed
focused (reply ESC[I) 18,796,851 (2.35 MB/s) 308,110
unfocused (reply ESC[O) 1,999 1

Reproduced three times at 33,860/s, 38,396/s and 41,447/s. Any terminal that answers a 1004 mode-set with the current focus state triggers this, so Ghostty is not a necessary condition.

Harness source
#!/usr/bin/env python3
"""Usage: repro.py <focused:0|1> <seconds>"""
import os, pty, sys, time, select, signal, fcntl, termios, struct

SET_1004 = b"\x1b[?1004h"
FOCUSED = sys.argv[1] == "1"
DURATION = float(sys.argv[2])
REPLY = b"\x1b[I" if FOCUSED else b"\x1b[O"

pid, fd = pty.fork()
if pid == 0:
    env = dict(os.environ)
    env.update({
        "TERM": "xterm-ghostty",
        "TERM_PROGRAM": "ghostty",
        "GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty",
        "COLUMNS": "114", "LINES": "32",
    })
    os.execvpe("jcode", ["jcode"], env)

fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 32, 114, 0, 0))

start = time.time()
total_bytes = reads = replies = 0
buf = b""
while time.time() - start < DURATION:
    r, _, _ = select.select([fd], [], [], 0.2)
    if not r:
        continue
    try:
        data = os.read(fd, 65536)
    except OSError:
        break
    if not data:
        break
    total_bytes += len(data)
    reads += 1
    buf = (buf + data)[-4096:]
    n = buf.count(SET_1004)
    if n:
        buf = buf.replace(SET_1004, b"")
        for _ in range(n):
            os.write(fd, REPLY)
            replies += 1

elapsed = time.time() - start
os.kill(pid, signal.SIGKILL)
os.waitpid(pid, 0)
print(f"focused={FOCUSED} elapsed={elapsed:.1f}s")
print(f"  bytes from jcode : {total_bytes:,} ({total_bytes/elapsed/1e6:.2f} MB/s)")
print(f"  ESC[?1004h seen  : {replies:,} ({replies/elapsed:.0f}/s)")

Why GNOME Terminal is unaffected

VTE reports focus transitions only, never in response to the mode being set, so nothing answers and no loop forms. That was the original clue: the same session in GNOME Terminal does not spin.

jcode has no terminal-specific branch here. perf.rs:208 sets enable_focus_change = true unconditionally, and only perf.rs:234 (WSL plus Windows Terminal) disables it. detect_terminal does recognise Ghostty, but that only feeds fragile_glyph_cache.

Ruled out by measurement

Each of these was changed on the live affected session with no effect:

hypothesis result
display.redraw_fps 60 -> 30 -> 10 ~42k writes/s in all three
display.performance = reduced, minimal unchanged
display.latex_rendering image -> text unchanged
display.mouse_capture = false 43,486 writes/s
display.prompt_entry_animation = false 42,320 writes/s
display.pin_todos = false 42,274 writes/s
display.prompt_preview = false 42,680 writes/s
X11 versus Wayland unchanged
disk logging log grew 0 KB/s during the spin

The redraw scheduler cannot cause this. redraw_schedule.rs:16-20 floors intervals at 250 ms and 5 s, and redraw_schedule.rs:428 returns REDRAW_DEEP_IDLE when !client_focused, so focus makes the schedule slower. The fastest scheduler path is 4 draws/s, three orders of magnitude below what I measured.

These writes never pass through ratatui. They go out through crossterm's queue/flush in mod.rs:155-179, so client:draw-stats cannot see them.

Related issues

Nothing open covers focus-triggered mode re-arming.

Fix

reapply_terminal_modes_to already takes a focus_change flag (mod.rs:159). The three focus-path callers should not re-arm the one mode whose reply re-triggers them. Non-focus callers keep re-arming it, since the defensive re-arm matters after an external program may have cleared the modes. Focus reporting is still enabled once at startup, so nothing changes functionally.

A patch doing this is on a branch, since this repo does not accept external pull requests: MatCer@74ae3a8

Three call sites pass false, plus a regression test on the emitted bytes. Measured on the same machine:

binary simulated state ESC[?1004h loops in 8s
v0.81.7 focused 331,577 (41,447/s), 20.2 MB
patched focused 1, 3.3 KB
patched unfocused 1, 2.2 KB

The remaining occurrence is the normal startup mode-set.

Impact

Any jcode user in Ghostty, or any terminal that reports focus state on mode-set, pays about one CPU core in jcode plus another in the terminal renderer for as long as the window is focused. I found this because the machine got hot whenever the jcode tab was in front. The workarounds are keeping the tab unfocused or using a different terminal.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    autonomous: clearHands-off: unambiguous bug, obvious fix, no decisions. Don't even look - an agent can fully solve.bugSomething isn't workingtriage: reproducibleClear repro + clear fix path

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions