fix: strip terminal query responses from buffer replay on reconnect (#301)
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -1054,6 +1055,27 @@ func (h *TerminalHandler) setupUserShellPtyAccess(
|
||||
return h.setupPtyAccessCommon(sessionID, processID, interactiveRunner, wsw, ptyWriter, getWriter)
|
||||
}
|
||||
|
||||
// terminalResponsePattern matches terminal query responses that should not be replayed.
|
||||
// These are responses from the terminal emulator to queries sent by the shell, which
|
||||
// appear as PTY output and get captured in the ring buffer. On replay they render as
|
||||
// visible garbage text because they are response sequences, not display sequences.
|
||||
//
|
||||
// Matched sequences:
|
||||
// - OSC 11 response: \x1b]11;rgb:XXXX/XXXX/XXXX ST (background color, ST = ESC\ or BEL)
|
||||
// - DA1 response: \x1b[?<params>c (device attributes)
|
||||
// - CPR response: \x1b[<row>;<col>R or \x1b[<row>R (cursor position report)
|
||||
var terminalResponsePattern = regexp.MustCompile(
|
||||
`\x1b]11;rgb:[0-9a-fA-F/]+(?:\x1b\\|\x07)` + // OSC 11 (ESC\ or BEL terminator)
|
||||
`|\x1b\[\?[0-9;]*c` + // DA1 response
|
||||
`|\x1b\[\d+(?:;\d+)?R`, // CPR response
|
||||
)
|
||||
|
||||
// stripTerminalResponses removes terminal query response sequences from data.
|
||||
// Used to clean buffered PTY output before replaying it on reconnect.
|
||||
func stripTerminalResponses(data []byte) []byte {
|
||||
return terminalResponsePattern.ReplaceAll(data, nil)
|
||||
}
|
||||
|
||||
// setupPtyAccessCommon is the shared implementation for setupPtyAccess and setupUserShellPtyAccess.
|
||||
// It gets a PTY writer via getWriter, sends buffered output, and sets up direct output.
|
||||
func (h *TerminalHandler) setupPtyAccessCommon(
|
||||
@@ -1080,14 +1102,18 @@ func (h *TerminalHandler) setupPtyAccessCommon(
|
||||
combined.WriteString(chunk.Data)
|
||||
}
|
||||
if combined.Len() > 0 {
|
||||
cleaned := stripTerminalResponses(combined.Bytes())
|
||||
h.logger.Debug("sending buffered output to terminal",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Int("chunks", len(chunks)),
|
||||
zap.Int("total_bytes", combined.Len()))
|
||||
if _, writeErr := wsw.Write(combined.Bytes()); writeErr != nil {
|
||||
h.logger.Warn("failed to send buffered output",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(writeErr))
|
||||
zap.Int("total_bytes", combined.Len()),
|
||||
zap.Int("cleaned_bytes", len(cleaned)))
|
||||
if len(cleaned) > 0 {
|
||||
if _, writeErr := wsw.Write(cleaned); writeErr != nil {
|
||||
h.logger.Warn("failed to send buffered output",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(writeErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
@@ -61,3 +62,86 @@ func TestCheckWebSocketOrigin(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTerminalResponses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
want []byte
|
||||
}{
|
||||
{
|
||||
name: "no sequences",
|
||||
input: []byte("hello world\r\n$ "),
|
||||
want: []byte("hello world\r\n$ "),
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: []byte{},
|
||||
want: []byte{},
|
||||
},
|
||||
{
|
||||
name: "OSC 11 response with ESC backslash",
|
||||
input: []byte("\x1b]11;rgb:1f1f/1f1f/1f1f\x1b\\"),
|
||||
want: []byte{},
|
||||
},
|
||||
{
|
||||
name: "OSC 11 response with BEL",
|
||||
input: []byte("\x1b]11;rgb:1f1f/1f1f/1f1f\x07"),
|
||||
want: []byte{},
|
||||
},
|
||||
{
|
||||
name: "DA1 response",
|
||||
input: []byte("\x1b[?1;2c"),
|
||||
want: []byte{},
|
||||
},
|
||||
{
|
||||
name: "DA1 response with multiple params",
|
||||
input: []byte("\x1b[?64;1;2;6;22c"),
|
||||
want: []byte{},
|
||||
},
|
||||
{
|
||||
name: "CPR response row;col",
|
||||
input: []byte("\x1b[5;1R"),
|
||||
want: []byte{},
|
||||
},
|
||||
{
|
||||
name: "CPR response row only",
|
||||
input: []byte("\x1b[1R"),
|
||||
want: []byte{},
|
||||
},
|
||||
{
|
||||
name: "only responses produces empty",
|
||||
input: []byte("\x1b]11;rgb:1f1f/1f1f/1f1f\x1b\\\x1b[?1;2c\x1b[5;1R"),
|
||||
want: []byte{},
|
||||
},
|
||||
{
|
||||
name: "mixed content preserves normal output",
|
||||
input: []byte("$ ls\r\nfile.txt\r\n\x1b]11;rgb:0000/0000/0000\x1b\\\x1b[?1;2c$ "),
|
||||
want: []byte("$ ls\r\nfile.txt\r\n$ "),
|
||||
},
|
||||
{
|
||||
name: "sequences between normal text",
|
||||
input: []byte("before\x1b[24;80Rafter"),
|
||||
want: []byte("beforeafter"),
|
||||
},
|
||||
{
|
||||
name: "multiple OSC 11 responses",
|
||||
input: []byte("\x1b]11;rgb:1f1f/1f1f/1f1f\x1b\\\x1b]11;rgb:ffff/ffff/ffff\x07"),
|
||||
want: []byte{},
|
||||
},
|
||||
{
|
||||
name: "preserves other escape sequences",
|
||||
input: []byte("\x1b[32mgreen\x1b[0m \x1b[?1;2c normal"),
|
||||
want: []byte("\x1b[32mgreen\x1b[0m normal"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := stripTerminalResponses(tt.input)
|
||||
if !bytes.Equal(got, tt.want) {
|
||||
t.Errorf("stripTerminalResponses(%q)\n got: %q\nwant: %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user