From 6dd6a6ef4cc77881a08b44b6dcf94ef8d15a3c5d Mon Sep 17 00:00:00 2001 From: cmj Date: Mon, 12 Oct 2026 17:42:02 +0800 Subject: [PATCH 1/2] fix(vtquery): answer device-status DSR with CSI 0 n CSI 5 n on a panel PTY now gets CSI 0 n, the ready report, written back on the same read as a device-attributes reply. Ps is exactly 5, and leading zeros count as that parameter. Cursor position, a private marker, and a second parameter stay unanswered. The bytes are CSI 0 n: that is the report a terminal sends, and the sequence a readline macro waits on. --- internal/ptymgr/ptymgr.go | 10 ++--- internal/vtquery/vtquery.go | 71 ++++++++++++++++++++------------ internal/vtquery/vtquery_test.go | 4 +- 3 files changed, 52 insertions(+), 33 deletions(-) diff --git a/internal/ptymgr/ptymgr.go b/internal/ptymgr/ptymgr.go index 26c91ed3..5a333ab4 100644 --- a/internal/ptymgr/ptymgr.go +++ b/internal/ptymgr/ptymgr.go @@ -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) } diff --git a/internal/vtquery/vtquery.go b/internal/vtquery/vtquery.go index f3888249..37e87494 100644 --- a/internal/vtquery/vtquery.go +++ b/internal/vtquery/vtquery.go @@ -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: // @@ -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...) diff --git a/internal/vtquery/vtquery_test.go b/internal/vtquery/vtquery_test.go index e6c7f5d6..b4a9f7cb 100644 --- a/internal/vtquery/vtquery_test.go +++ b/internal/vtquery/vtquery_test.go @@ -72,8 +72,8 @@ 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"}, {"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", ""}, From adcca51b863776750286c9103e0c118e3aab5ab8 Mon Sep 17 00:00:00 2001 From: cmj Date: Mon, 12 Oct 2026 17:42:56 +0800 Subject: [PATCH 2/2] test(vtquery): pin which DSR the server answers Leading zeros, an embedded query, and a mix with DA come back in order. Parameter 15, parameter 50, a private marker, a second parameter, and both cursor reports stay empty. The pump test reads CSI 0 n back on the program's stdin after it writes CSI 5 n. --- internal/ptymgr/reply_test.go | 47 ++++++++++++++++++++++++++++++++ internal/vtquery/vtquery_test.go | 12 ++++++++ 2 files changed, 59 insertions(+) diff --git a/internal/ptymgr/reply_test.go b/internal/ptymgr/reply_test.go index 551812d2..dc3cb7a9 100644 --- a/internal/ptymgr/reply_test.go +++ b/internal/ptymgr/reply_test.go @@ -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) + } +} diff --git a/internal/vtquery/vtquery_test.go b/internal/vtquery/vtquery_test.go index b4a9f7cb..e439ec1c 100644 --- a/internal/vtquery/vtquery_test.go +++ b/internal/vtquery/vtquery_test.go @@ -74,6 +74,18 @@ func TestReply(t *testing.T) { {"no query", "plain \x1b[31mred\x1b[0m text", ""}, {"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", ""},