Skip to content

Terminal Escape Codes Leak to stdout During Helix Startup #15284

@taspelund

Description

@taspelund

Summary

When starting Helix, terminal escape sequences are printed to the terminal:
^[[?0u^[[?2026;2$y^[P1$r0;48:2::59:34:76m^[^[[?62;22;52c^[[?997;1n

This is most visible on:

  • SSH connections with network latency
  • Slow terminals or systems
  • Any environment where terminal response time > 100ms

Full disclosure, I hit this issue and used claude code to help me debug it. The issue seems to have been root caused by claude, which looks to be related to a race condition in how the terminal buffer is handled during startup:

During TerminaBackend::new(), the detect_capabilities() function:

  1. Enters raw mode
  2. Sends terminal capability queries (Kitty keyboard protocol, synchronized output, true color,
    etc.)
  3. Polls for responses with a 100ms timeout
  4. Exits raw mode before returning
  5. Terminal responses arriving after the timeout remain buffered in the PTY
  6. When control returns to the shell/stdout, these buffered responses leak through

There's a critical window between when detect_capabilities() exits raw mode and when
terminal.claim() re-enters raw mode (via Application::new()app.run()). Any terminal responses
arriving during this window are echoed to stdout instead of being consumed by the event stream.

Claude provided multiple options for ways to fix this, but the one that seemed to address it most fundamentally (as opposed to extending timeouts to make this less likely) is this:

Fix

Keep the terminal in raw mode after capability detection until claim() is called. This ensures
all terminal responses remain buffered by the terminal driver.

Changes in helix-tui/src/backend/termina.rs:

  1. Add state tracking: is_raw_mode: bool field to TerminaBackend
  2. Remove terminal.enter_cooked_mode() call at end of detect_capabilities()
  3. Make claim() conditionally enter raw mode only if not already in it
  4. Update restore() to set is_raw_mode = false
  5. Add defensive cleanup in Drop to exit raw mode if still active

diff:

diff --git a/helix-tui/src/backend/termina.rs b/helix-tui/src/backend/termina.rs
index f1f99895..8ecbc0d6 100644
--- a/helix-tui/src/backend/termina.rs
+++ b/helix-tui/src/backend/termina.rs
@@ -76,6 +76,7 @@ pub struct TerminaBackend {
     capabilities: Capabilities,
     reset_cursor_command: String,
     is_synchronized_output_set: bool,
+    is_raw_mode: bool,
 }

 impl TerminaBackend {
@@ -112,6 +113,7 @@ pub fn new(config: Config) -> io::Result<Self> {
             capabilities,
             reset_cursor_command,
             is_synchronized_output_set: false,
+            is_raw_mode: true,
         })
     }

@@ -238,7 +240,8 @@ fn detect_capabilities(
             log::debug!("terminfo could not be read, using default cursor reset escape sequence: {reset_cursor_command:?}");
         }

-        terminal.enter_cooked_mode()?;
+        // Stay in raw mode - claim() will handle alternate screen setup
+        // Raw mode prevents terminal responses from leaking to stdout

         Ok((capabilities, reset_cursor_command))
     }
@@ -376,7 +379,12 @@ fn end_sychronized_render(&mut self) -> io::Result<()> {

 impl Backend for TerminaBackend {
     fn claim(&mut self) -> io::Result<()> {
-        self.terminal.enter_raw_mode()?;
+        // enter_raw_mode() is idempotent, but we track state for clarity
+        // and to avoid unnecessary syscalls on SIGCONT resume
+        if !self.is_raw_mode {
+            self.terminal.enter_raw_mode()?;
+            self.is_raw_mode = true;
+        }

         write!(
             self.terminal,
@@ -421,6 +429,7 @@ fn restore(&mut self) -> io::Result<()> {
         )?;
         self.terminal.flush()?;
         self.terminal.enter_cooked_mode()?;
+        self.is_raw_mode = false;
         Ok(())
     }

@@ -587,8 +596,12 @@ fn drop(&mut self) {
                 decreset!(FocusTracking),
                 decreset!(ClearAndEnableAlternateScreen),
             );
-            // NOTE: Drop for Platform terminal resets the mode and flushes the buffer when not
-            // panicking.
+            // Ensure we exit raw mode if we're in it
+            if self.is_raw_mode {
+                let _ = self.terminal.enter_cooked_mode();
+            }
+            // NOTE: PlatformTerminal's Drop also resets the mode, providing a second
+            // layer of safety
         }
     }
 }

Reproduction Steps

hx ~/.zshrc -> :q

Simply opening helix and closing it is enough to reproduce this issue if the timing event lines up properly.
I observed this most frequently over an ssh session running across coffee shop wifi, which intermittently has latency spikes that make this more likely to appear.

It is possible to force this issue to be more reliably reproducible (>50% of the time) by compiling with poll_duration set to Duration::ZERO or Duration::from_millis(1) (helix-tui/src/backend/termina.rs line 177). This is much easier than waiting for wifi to become slow or by trying to influence this using linux tc or similar.

Helix log

N/A

Platform

Illumos

Terminal Emulator

Ghostty 1.2.3 (stable)

Installation Method

source

Helix Version

helix 25.07.1 (066dded)

Metadata

Metadata

Assignees

No one assigned

    Labels

    C-bugCategory: This is a bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions