Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions internal/ptymgr/ptymgr.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,11 +271,11 @@ func (m *Manager) pump(id string, p *pane, cmd *exec.Cmd) {
if n > 0 {
chunk := append([]byte(nil), buf[:n]...)
end := m.appendRing(p, chunk)
// Answer device-attributes queries on the PTY the way a real terminal does.
// baton keeps no server-side emulator, so without this the only responder is
// the client's emulator, whose reply round-trips back as late input; the client
// therefore strips these queries, leaving a full-screen program (nvim) blocked
// on a handshake reply that never comes — notably wedging Ctrl-Z suspend.
// Answer state-independent terminal queries on the PTY the way a real terminal
// does. baton keeps no server-side emulator, and the client strips these
// queries, so without this write a program blocked on the reply never resumes.
// nvim's Ctrl-Z waits on device attributes; a readline macro bound to CSI 0 n
// waits on device-status DSR (CSI 5 n).
if reply := vtquery.Reply(chunk); len(reply) > 0 {
_, _ = p.f.Write(reply)
}
Expand Down
47 changes: 47 additions & 0 deletions internal/ptymgr/reply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,50 @@ sys.stdout.write("REPLY:" + data.hex() + ":END\r\n"); sys.stdout.flush()
t.Fatalf("program did not receive the DA reply on stdin.\ngot: %q\nwant REPLY to contain %q", got, wantHex)
}
}

// TestPumpAnswersDeviceStatus is the same round trip for device-status DSR.
// A program writes CSI 5 n and must read CSI 0 n back on its stdin. The client
// strips the query, so pump is the only writer. Cursor-position DSR is not this
// reply: the bytes are 1b 5b 30 6e, with no private marker.
func TestPumpAnswersDeviceStatus(t *testing.T) {
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 unavailable")
}
const prog = `
import sys, os, tty, termios, select
fd = 0
old = termios.tcgetattr(fd)
tty.setraw(fd)
sys.stdout.write("\x1b[5n"); sys.stdout.flush() # device-status DSR
r, _, _ = select.select([fd], [], [], 2.0)
data = os.read(fd, 64) if r else b""
termios.tcsetattr(fd, termios.TCSADRAIN, old)
sys.stdout.write("REPLY:" + data.hex() + ":END\r\n"); sys.stdout.flush()
`
var mu sync.Mutex
var out strings.Builder
m := New()
m.OnOutput(func(_ string, d []byte, _ int64) { mu.Lock(); out.Write(d); mu.Unlock() })
if err := m.StartCmd("p", Spec{Command: "python3", Args: []string{"-c", prog}}); err != nil {
t.Skipf("python3 start: %v", err)
}
defer m.Stop("p")

deadline := time.Now().Add(4 * time.Second)
var got string
for time.Now().Before(deadline) {
mu.Lock()
got = out.String()
mu.Unlock()
if strings.Contains(got, ":END") {
break
}
time.Sleep(50 * time.Millisecond)
}

// ESC [ 0 n
const wantHex = "1b5b306e"
if !strings.Contains(got, "REPLY:"+wantHex+":END") {
t.Fatalf("program did not receive the status reply on stdin.\ngot: %q\nwant REPLY to contain %q", got, wantHex)
}
}
71 changes: 45 additions & 26 deletions internal/vtquery/vtquery.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Package vtquery strips terminal query / report-triggering escape sequences from a
// byte stream before it is fed to a client-side emulator.
// byte stream before it is fed to a client-side emulator, and answers the queries
// that do not need screen state.
//
// baton has no server-side emulator, so the CLIENT's emulator is what answers a
// program's terminal queries (device attributes, cursor position, background colour) —
// its replies go back to the program as panel.input so the program gets its answer.
// baton has no server-side emulator. Left alone, the CLIENT's emulator answers a
// program's terminal queries (device attributes, cursor position, background colour)
// and those replies go back to the program as panel.input.
//
// That answering is the problem in two places baton feeds an emulator:
//
Expand Down Expand Up @@ -59,44 +60,62 @@ func Strip(b []byte) []byte {
return queries.ReplaceAll(b, nil)
}

// deviceAttrs matches a device-attributes query and captures its private-marker so the
// primary (DA1), secondary (DA2) and tertiary (DA3) forms can be told apart:
// answered matches the queries the server can answer without an emulator, in the
// order they appear in the chunk:
//
// CSI Ps c → DA1 (marker "")
// CSI > Ps c → DA2 (marker ">")
// CSI = Ps c → DA3 (marker "=")
// CSI 0* 5 n device-status DSR. Ps is exactly 5; leading zeros are the same
// parameter. No private marker, no second parameter.
// CSI [>=]? … c DA1 (marker ""), DA2 (marker ">"), DA3 (marker "=").
//
// The marker set is exactly [>=] so it never matches a CSI ending in c that carries a
// different private marker or an intermediate byte (e.g. CSI > 4 ; 0 m is not a c).
var deviceAttrs = regexp.MustCompile(`\x1b\[([>=]?)[0-9;]*c`)
// The DSR alternative is tried first so CSI 5 n is not read as a DA. The marker set
// is exactly [>=], so a CSI ending in c with another private marker or an
// intermediate byte (CSI > 4 ; 0 m) is not a DA. CSI 6 n, CSI ? 5 n, CSI ? 6 n,
// CSI 15 n and CSI 5 ; 6 n do not match.
var answered = regexp.MustCompile(`\x1b\[(?:0*5n|([>=]?)[0-9;]*c)`)

// Canned answers, byte-identical to what the client-side x/vt emulator returns, so the
// server responder is a drop-in for the emulator that answered these live before the
// live-strip landed. A DA reply is state-independent, which is exactly why the server —
// which keeps no emulator — can answer it.
// Canned answers. The DA bytes match the client-side x/vt emulator, so the server
// responder is a drop-in for the emulator that answered these live before the
// live-strip landed. Both replies are state-independent, which is why a server
// that keeps no emulator can write them.
const (
da1Reply = "\x1b[?62;1;6;22c" // VT220 + 132-col + selective-erase + ANSI colour
da2Reply = "\x1b[>1;10;0c" // VT220, firmware 10, no cartridge
// Operating status: ready, no malfunction (ECMA-48 / VT510 DSR). This is CSI 0 n.
// x/vt emits CSI ? 0 n here because it passes a DEC status type; that is not the
// reply a terminal gives, and a binding on CSI 0 n would never see it.
dsrReady = "\x1b[0n"
)

// Reply returns the terminal's canned answers to the device-attributes queries present
// in b, in the order they appear, so a server with no emulator can answer them on the
// PTY the way a real terminal does — immediately, before a round-tripped emulator reply
// could arrive late at the program's prompt as garbage. A full-screen program (vim,
// nvim) blocks on this reply during its terminal handshake, including around suspend, so
// a missing answer wedges it. State-dependent queries (cursor position DSR 6 n, mode
// DECRQM, colours) are deliberately NOT answered here — only the client emulator, which
// tracks screen state, can answer those; they stay live-answered as before.
// Reply returns canned answers for the queries in b that do not need screen state,
// in the order they appear, so pump can write them on the PTY the way a real
// terminal does. The write happens on the read that carried the query, before a
// round-tripped emulator reply could arrive late at the prompt as garbage.
//
// Answered:
// - DA1 and DA2. A full-screen program blocks on DA during its handshake,
// including around suspend, so a missing answer wedges it (nvim Ctrl-Z).
// DA3 gets nothing, matching the emulator.
// - Device-status DSR, CSI 5 n. The ready reply is always CSI 0 n. A readline
// macro bound to that sequence never runs while the PTY stays silent.
//
// Not answered, because the bytes depend on state this server does not keep:
// cursor-position DSR (CSI 6 n, CSI ? 6 n), DECRQM, colour queries, XTVERSION,
// and the in-band-resize report. Strip removes those before the client emulator,
// so nothing answers them. A canned row, mode bit, or colour would be a lie.
//
// Returns nil when b holds no answerable query, so the common no-query chunk is
// allocation-free.
// allocation-free. A chunk whose only query is DA3 also returns nil.
func Reply(b []byte) []byte {
ms := deviceAttrs.FindAllSubmatch(b, -1)
ms := answered.FindAllSubmatch(b, -1)
if ms == nil {
return nil
}
var out []byte
for _, m := range ms {
if m[0][len(m[0])-1] == 'n' { // device-status DSR; group 1 did not participate
out = append(out, dsrReady...)
continue
}
switch string(m[1]) {
case "": // DA1
out = append(out, da1Reply...)
Expand Down
16 changes: 14 additions & 2 deletions internal/vtquery/vtquery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,20 @@ func TestReply(t *testing.T) {
{"DA3 tertiary has no canned reply", "\x1b[=c", ""},
{"two queries answered in order", "\x1b[c\x1b[>c", da1Reply + da2Reply},
{"no query", "plain \x1b[31mred\x1b[0m text", ""},
{"cursor-position DSR is left to the client emulator", "\x1b[6n", ""},
{"device-status DSR is not a DA", "\x1b[5n", ""},
{"cursor-position DSR gets no server reply", "\x1b[6n", ""},
{"device-status DSR answers ready", "\x1b[5n", "\x1b[0n"},
{"device-status DSR with leading zeros", "\x1b[05n", "\x1b[0n"},
{"device-status DSR embedded in output", "hi\x1b[5nbye", "\x1b[0n"},
{"two device-status DSR answered in order", "\x1b[5n\x1b[05n", "\x1b[0n\x1b[0n"},
{"DSR then DA answered in order", "\x1b[5n\x1b[c", "\x1b[0n" + da1Reply},
{"DA then DSR answered in order", "\x1b[c\x1b[5n", da1Reply + "\x1b[0n"},
{"DA2 then DSR answered in order", "\x1b[>0c\x1b[5n", da2Reply + "\x1b[0n"},
{"DA3 contributes nothing beside a DSR", "\x1b[=c\x1b[5n", "\x1b[0n"},
{"parameter 15 is not status DSR", "\x1b[15n", ""},
{"parameter 50 is not status DSR", "\x1b[50n", ""},
{"private DSR is not status DSR", "\x1b[?5n", ""},
{"extra parameter is not status DSR", "\x1b[5;6n", ""},
{"extended cursor-position DSR gets no server reply", "\x1b[?6n", ""},
{"DECRQM mode query is not a DA", "\x1b[?2026$p", ""},
{"modifyOtherKeys reset is not a DA query", "\x1b[>4;0m", ""}, // ends in m, seen in nvim output
{"in-band-resize enable is not a DA", "\x1b[?2048h", ""},
Expand Down
Loading