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:
- jcode calls
reapply_configured_terminal_modes() inside its Event::FocusGained handler, which re-issues ESC[?1004h (EnableFocusChange).
- 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.
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:
reapply_configured_terminal_modes()inside itsEvent::FocusGainedhandler, which re-issuesESC[?1004h(EnableFocusChange).CSI I, which crossterm decodes asEvent::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
Cargo.lock:1426-1428)Reproduction
Open a Ghostty tab, run
jcode, type nothing. Sample the client:Sample unfocused, then focused, then unfocused again.
Ghostty threads while focused: renderer 104-106%, io-reader 29%, io 25%. A Ghostty window running
sleep 600sits at 0%, so Ghostty alone does not spin.Root cause
crates/jcode-tui/src/tui/app/local.rs:397: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:Same call at
remote.rs:395andremote.rs:772. The latter is inhandle_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: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:
Unfocused, Ghostty replies
CSI O,FocusLostdoes not reapply, and it stops after one hop.The payload size matches:
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 seeingb"\x1b[?1004h"from the slave, write backb"\x1b[I"for focused orb"\x1b[O"for unfocused. Dumb sink otherwise.Same binary, same environment, 8 second runs, one byte of difference in the reply:
ESC[?1004hobservedESC[I)ESC[O)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
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:208setsenable_focus_change = trueunconditionally, and onlyperf.rs:234(WSL plus Windows Terminal) disables it.detect_terminaldoes recognise Ghostty, but that only feedsfragile_glyph_cache.Ruled out by measurement
Each of these was changed on the live affected session with no effect:
display.redraw_fps60 -> 30 -> 10display.performance=reduced,minimaldisplay.latex_renderingimage -> textdisplay.mouse_capture= falsedisplay.prompt_entry_animation= falsedisplay.pin_todos= falsedisplay.prompt_preview= falseThe redraw scheduler cannot cause this.
redraw_schedule.rs:16-20floors intervals at 250 ms and 5 s, andredraw_schedule.rs:428returnsREDRAW_DEEP_IDLEwhen!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/flushinmod.rs:155-179, soclient:draw-statscannot see them.Related issues
RunningToolforcing a full-frame flush at roughly 1/s. Different mechanism and four orders of magnitude apart.Nothing open covers focus-triggered mode re-arming.
Fix
reapply_terminal_modes_toalready takes afocus_changeflag (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:ESC[?1004hloops in 8sThe 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.