Skip to content

Terminal duplicates CSI 6n response after OSC 11 query #4013

@wesm

Description

@wesm

Terminal duplicates CSI 6n response after OSC 11 query, leaking escape replies into shell input

Summary

Superset appears to duplicate terminal cursor-position responses when a foreground process sends an OSC 11 background-color query immediately followed by a CSI 6n cursor-position query.

This breaks terminal capability probing used by Go terminal libraries such as termenv, lipgloss, and bubbletea. In real CLI usage, the extra terminal response can leak into the next shell prompt as text like:

execute: 1515/1111/1010\[4;1R[4;1R_

The same repro behaves correctly in kitty directly on the same machine.

This report was drafted with assistance from OpenAI Codex. The repro commands and observed outputs below were run manually by Wes McKinney.

Environment

  • Superset terminal session
  • Shell: zsh
  • TERM=xterm-256color
  • TMUX unset in the minimal repro
  • Comparison terminal: kitty directly on the same machine
  • macOS host

Minimal Repro

This self-contained Go program opens /dev/tty, sets non-canonical/no-echo mode, sends terminal queries, and reads responses back from /dev/tty.

It supports three modes:

/tmp/term-query-repro osc
/tmp/term-query-repro cpr
/tmp/term-query-repro both

Queries:

  • osc: sends ESC ] 11 ; ? ESC \
  • cpr: sends ESC [ 6 n
  • both: sends ESC ] 11 ; ? ESC \ ESC [ 6 n
Repro source
package main

import (
	"fmt"
	"io"
	"os"
	"os/exec"
	"strings"
	"time"
)

func main() {
	mode := "both"
	if len(os.Args) > 1 {
		mode = os.Args[1]
	}
	if mode != "both" && mode != "osc" && mode != "cpr" {
		fmt.Fprintf(os.Stderr, "usage: %s [both|osc|cpr]\n", os.Args[0])
		os.Exit(2)
	}

	tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
	if err != nil {
		fmt.Fprintf(os.Stderr, "open /dev/tty: %v\n", err)
		os.Exit(1)
	}
	defer tty.Close()

	oldState, err := stty(tty, "-g")
	if err != nil {
		fmt.Fprintf(os.Stderr, "stty -g: %v\n", err)
		os.Exit(1)
	}
	defer func() { _, _ = stty(tty, oldState) }()

	if _, err := stty(tty, "-echo", "-icanon", "min", "0", "time", "2"); err != nil {
		fmt.Fprintf(os.Stderr, "stty raw-ish: %v\n", err)
		os.Exit(1)
	}

	query, description := queryFor(mode)
	fmt.Printf("pid=%d TERM=%q TMUX_set=%v mode=%s\n", os.Getpid(), os.Getenv("TERM"), os.Getenv("TMUX") != "", mode)
	fmt.Println("sending " + description)

	if _, err := tty.WriteString(query); err != nil {
		fmt.Fprintf(os.Stderr, "write query: %v\n", err)
		os.Exit(1)
	}

	deadline := time.Now().Add(2 * time.Second)
	var got []byte
	buf := make([]byte, 256)
	for time.Now().Before(deadline) {
		n, err := tty.Read(buf)
		if n > 0 {
			got = append(got, buf[:n]...)
			if completeFor(mode, got) {
				break
			}
		}
		if err != nil && err != io.EOF {
			fmt.Fprintf(os.Stderr, "read response: %v\n", err)
			os.Exit(1)
		}
		time.Sleep(20 * time.Millisecond)
	}

	fmt.Printf("read %d bytes from /dev/tty\n", len(got))
	fmt.Printf("raw: %q\n", got)
	fmt.Printf("escaped: %s\n", escape(got))
	fmt.Printf("OSC 11 response count: %d\n", countOSC11(got))
	fmt.Printf("cursor-position response count: %d\n", countCPR(got))
	fmt.Printf("has OSC 11 response: %v\n", hasOSC11(got))
	fmt.Printf("has cursor-position response: %v\n", hasCPR(got))
	fmt.Println("after this exits, type Enter. If query text appears at the next prompt, the response leaked.")
}

func queryFor(mode string) (string, string) {
	switch mode {
	case "osc":
		return "\x1b]11;?\x1b\\", "OSC 11 background-color query only"
	case "cpr":
		return "\x1b[6n", "CSI 6n cursor-position query only"
	default:
		return "\x1b]11;?\x1b\\\x1b[6n", "OSC 11 background-color query and CSI 6n cursor-position query"
	}
}

func completeFor(mode string, b []byte) bool {
	switch mode {
	case "osc":
		return hasOSC11(b)
	case "cpr":
		return hasCPR(b)
	default:
		return hasOSC11(b) && hasCPR(b)
	}
}

func stty(tty *os.File, args ...string) (string, error) {
	cmd := exec.Command("stty", args...)
	cmd.Stdin = tty
	cmd.Stderr = os.Stderr
	out, err := cmd.Output()
	return strings.TrimSpace(string(out)), err
}

func hasOSC11(b []byte) bool {
	return countOSC11(b) > 0
}

func hasCPR(b []byte) bool {
	return countCPR(b) > 0
}

func countOSC11(b []byte) int {
	return strings.Count(string(b), "\x1b]11;")
}

func countCPR(b []byte) int {
	s := string(b)
	count := 0
	for i := 0; i < len(s); i++ {
		if !strings.HasPrefix(s[i:], "\x1b[") {
			continue
		}
		j := i + len("\x1b[")
		for j < len(s) && (s[j] == ';' || (s[j] >= '0' && s[j] <= '9')) {
			j++
		}
		if j < len(s) && s[j] == 'R' {
			count++
			i = j
		}
	}
	return count
}

func escape(b []byte) string {
	var out strings.Builder
	for _, c := range b {
		switch c {
		case '\x1b':
			out.WriteString("<ESC>")
		case '\a':
			out.WriteString("<BEL>")
		case '\r':
			out.WriteString("<CR>")
		case '\n':
			out.WriteString("<LF>")
		default:
			if c < 0x20 || c == 0x7f {
				fmt.Fprintf(&out, "<0x%02x>", c)
			} else {
				out.WriteByte(c)
			}
		}
	}
	return out.String()
}

Expected Behavior

both should return exactly:

  • one OSC 11 response
  • one cursor-position response

Kitty direct example:

pid=60405 TERM="xterm-256color" TMUX_set=false
sending OSC 11 background-color query and CSI 6n cursor-position query
read 32 bytes from /dev/tty
raw: "\x1b]11;rgb:0000/0000/0000\x1b\\\x1b[88;1R"
escaped: <ESC>]11;rgb:0000/0000/0000<ESC>\<ESC>[88;1R
has OSC 11 response: true
has cursor-position response: true

No query response text should appear at the next shell prompt after the process exits.

Actual Behavior In Superset

osc alone behaves correctly:

$ /tmp/term-query-repro osc
pid=2306 TERM="xterm-256color" TMUX_set=false mode=osc
sending OSC 11 background-color query only
read 25 bytes from /dev/tty
raw: "\x1b]11;rgb:1515/1111/1010\x1b\\"
escaped: <ESC>]11;rgb:1515/1111/1010<ESC>\
OSC 11 response count: 1
cursor-position response count: 0
has OSC 11 response: true
has cursor-position response: false

cpr alone also returns one cursor-position response:

$ /tmp/term-query-repro cpr
pid=17734 TERM="xterm-256color" TMUX_set=false mode=cpr
sending CSI 6n cursor-position query only
read 7 bytes from /dev/tty
raw: "\x1b[51;1R"
escaped: <ESC>[51;1R
OSC 11 response count: 0

But both duplicates the cursor-position response:

$ /tmp/term-query-repro both
pid=5956 TERM="xterm-256color" TMUX_set=false mode=both
sending OSC 11 background-color query and CSI 6n cursor-position query
read 39 bytes from /dev/tty
raw: "\x1b]11;rgb:1515/1111/1010\x1b\\\x1b[35;1R\x1b[35;1R"
escaped: <ESC>]11;rgb:1515/1111/1010<ESC>\<ESC>[35;1R<ESC>[35;1R
OSC 11 response count: 1
cursor-position response count: 2
has OSC 11 response: true
has cursor-position response: true

The process only sent one CSI 6n query, so receiving two ESC[row;colR replies appears incorrect.

Real-World Impact

Libraries such as termenv intentionally send OSC 11 followed by CSI 6n. The cursor-position query is used as a synchronization marker so the process can know when terminal responses are complete.

If Superset sends two CPR replies for one CSI 6n query, programs that correctly read one OSC response and one CPR response can still leave an extra CPR in the terminal input stream. That extra response can then be consumed by the next shell prompt as user input.

This affects ordinary non-TUI commands too, because Bubble Tea / Lip Gloss / termenv can perform terminal probing before application command dispatch. I originally hit this via Git hooks invoking a Go CLI that imports Bubble Tea:

^[]11;rgb:1515/1111/1010^[\
^[[18;91R
execute: 1515/1111/1010\[18;145R[18;145R]...

Suspected Bug

Superset's terminal/session layer appears to forward or generate the CSI 6n cursor-position response twice when it immediately follows an OSC 11 query. The issue does not reproduce for OSC 11 alone or CSI 6n alone.

The duplicate response likely explains why terminal query responses sometimes leak into the next shell prompt/input line.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions