From b8b5994527701b5e32045efda6137855feb3729f Mon Sep 17 00:00:00 2001 From: realrasengan Date: Sat, 14 Mar 2026 20:34:30 -0700 Subject: [PATCH] server: VT emulator framebuffer diffing Replace raw PTY passthrough with proper VT emulator processing. PTY output feeds into charmbracelet/x/vt, snapshots are diffed against the last-acked framebuffer using CUP-based positioning. Fixes character doubling during fast typing. --- client_test.go | 81 ++++++++++++++++++++++++++++++++++++--- framebuffer.go | 80 ++++++++++++++++++++++++++++++++++++++ go.mod | 21 +++++++++- go.sum | 36 +++++++++++++++++ server.go | 102 +++++++++++++++++++++++++++++++++---------------- server_test.go | 20 +++++++--- 6 files changed, 296 insertions(+), 44 deletions(-) diff --git a/client_test.go b/client_test.go index 26369ad..2768396 100644 --- a/client_test.go +++ b/client_test.go @@ -3,6 +3,7 @@ package mosh import ( "io" "os/exec" + "regexp" "strconv" "strings" "sync" @@ -10,6 +11,12 @@ import ( "time" ) +var clientAnsiRe = regexp.MustCompile(`\x1b\[[0-9;?]*[A-Za-z]`) + +func clientStripANSI(s string) string { + return clientAnsiRe.ReplaceAllString(s, "") +} + // TestClientServerE2E tests the Go client against the Go server. func TestClientServerE2E(t *testing.T) { srv, err := NewServer("/bin/sh", 0, 0) @@ -48,13 +55,13 @@ func TestClientServerE2E(t *testing.T) { for { select { case <-deadline: - t.Fatalf("marker not echoed. Got: %q", allOutput) + t.Fatalf("marker not echoed. Got: %q", clientStripANSI(allOutput)) default: } out := client.Recv(500 * time.Millisecond) if out != nil { allOutput += string(out) - if strings.Contains(allOutput, marker) { + if strings.Contains(clientStripANSI(allOutput), marker) { t.Log("Go client → Go server E2E passed") return } @@ -103,7 +110,8 @@ func TestClientResize(t *testing.T) { out := client.Recv(500 * time.Millisecond) if out != nil { allOutput += string(out) - if strings.Contains(allOutput, "132") && strings.Contains(allOutput, "43") { + stripped := clientStripANSI(allOutput) + if strings.Contains(stripped, "132") && strings.Contains(stripped, "43") { t.Log("Go client resize verified: 132x43") return } @@ -165,7 +173,7 @@ func TestClientServeRW(t *testing.T) { out := client.Recv(500 * time.Millisecond) if out != nil { allOutput += string(out) - if strings.Contains(allOutput, "hello from latch") { + if strings.Contains(clientStripANSI(allOutput), "hello from latch") { break } } @@ -194,6 +202,14 @@ func TestClientServeRW(t *testing.T) { t.Fatal("timed out waiting for keystrokes on pipe") } + // Drain any extra data from the pipe (cumulative diffs may resend keystrokes). + go func() { + buf := make([]byte, 4096) + for { + inR.Read(buf) + } + }() + // Resize. client.Resize(200, 50) time.Sleep(500 * time.Millisecond) @@ -284,7 +300,7 @@ func TestClientAgainstRealMoshServer(t *testing.T) { recv := client.Recv(500 * time.Millisecond) if recv != nil { allOutput += string(recv) - if strings.Contains(allOutput, marker) { + if strings.Contains(clientStripANSI(allOutput), marker) { t.Log("Go client → real mosh-server E2E passed") return } @@ -292,6 +308,61 @@ func TestClientAgainstRealMoshServer(t *testing.T) { } } +// TestFastTypingNoDuplication verifies that rapid keystrokes don't cause +// character doubling due to overlapping diffs from the same base. +func TestFastTypingNoDuplication(t *testing.T) { + srv, err := NewServer("/bin/sh", 0, 0) + if err != nil { + t.Fatal(err) + } + go srv.Serve() + defer srv.Close() + <-srv.started + + client, err := Dial("127.0.0.1", srv.Port(), srv.KeyBase64()) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + // Wait for shell prompt. + for i := 0; i < 40; i++ { + if out := client.Recv(500 * time.Millisecond); len(out) > 0 { + break + } + } + + // Send "echo TESTMARKER\n" as rapid individual keystrokes with no delay. + cmd := "echo TESTMARKER\n" + for _, ch := range cmd { + client.Send([]byte{byte(ch)}) + } + + // Collect output until we see TESTMARKER in the echo output. + var allOutput string + deadline := time.After(10 * time.Second) + for { + select { + case <-deadline: + t.Fatalf("marker not echoed. Got: %q", clientStripANSI(allOutput)) + default: + } + out := client.Recv(500 * time.Millisecond) + if out != nil { + allOutput += string(out) + stripped := clientStripANSI(allOutput) + if strings.Contains(stripped, "TESTMARKER") { + // Check that TESTMARKER appears but TTEESSTTMMAARRKKEERR does not. + if strings.Contains(stripped, "TTEESSTTMMAARRKKEERR") { + t.Fatalf("character doubling detected: %q", stripped) + } + t.Log("fast typing test passed: no character duplication") + return + } + } + } +} + // parseMoshConnect extracts port and key from mosh-server output. func parseMoshConnect(output string) (int, string) { for _, line := range strings.Split(output, "\n") { diff --git a/framebuffer.go b/framebuffer.go index fdd49c2..cb27341 100644 --- a/framebuffer.go +++ b/framebuffer.go @@ -1,7 +1,13 @@ package mosh import ( + "image/color" "strconv" + "unicode/utf8" + + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/vt" ) // Framebuffer represents a terminal screen state for mosh SSP. @@ -361,3 +367,77 @@ func encodeRune(buf []byte, r rune) int { return 4 } } + +// SnapshotEmulator captures the VT emulator state into a Framebuffer. +func SnapshotEmulator(emu *vt.Emulator, cursorVisible bool) *Framebuffer { + w := emu.Width() + h := emu.Height() + fb := NewFramebuffer(w, h) + + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + c := emu.CellAt(x, y) + if c == nil { + continue + } + cell := &fb.Cells[y*w+x] + + // Content → first rune. + if c.Content == "" || c.Content == " " { + cell.Rune = ' ' + } else { + r, _ := utf8.DecodeRuneInString(c.Content) + if r == utf8.RuneError { + cell.Rune = ' ' + } else { + cell.Rune = r + } + } + + cell.Width = c.Width + if cell.Width == 0 && cell.Rune == ' ' { + // Continuation cell. + cell.Width = 0 + } + + // Attributes. + cell.Attr.Bold = c.Style.Attrs&uv.AttrBold != 0 + cell.Attr.Dim = c.Style.Attrs&uv.AttrFaint != 0 + cell.Attr.Italic = c.Style.Attrs&uv.AttrItalic != 0 + cell.Attr.Blink = c.Style.Attrs&uv.AttrBlink != 0 + cell.Attr.Reverse = c.Style.Attrs&uv.AttrReverse != 0 + cell.Attr.Strike = c.Style.Attrs&uv.AttrStrikethrough != 0 + cell.Attr.Under = c.Style.Underline != uv.UnderlineNone + + cell.Attr.FG = convertColor(c.Style.Fg) + cell.Attr.BG = convertColor(c.Style.Bg) + } + } + + pos := emu.CursorPosition() + fb.CurX = pos.X + fb.CurY = pos.Y + fb.CurVis = cursorVisible + + return fb +} + +// convertColor converts a color.Color from the VT emulator to our Color type. +func convertColor(c color.Color) Color { + if c == nil { + return Color{Type: ColorDefault} + } + switch v := c.(type) { + case ansi.BasicColor: + return Color{Type: ColorIndex, Value: uint32(v)} + case ansi.IndexedColor: + return Color{Type: ColorIndex, Value: uint32(v)} + case ansi.TrueColor: + return Color{Type: ColorRGB, Value: uint32(v)} + case color.RGBA: + return Color{Type: ColorRGB, Value: uint32(v.R)<<16 | uint32(v.G)<<8 | uint32(v.B)} + default: + r, g, b, _ := c.RGBA() + return Color{Type: ColorRGB, Value: uint32(r>>8)<<16 | uint32(g>>8)<<8 | uint32(b>>8)} + } +} diff --git a/go.mod b/go.mod index abd7321..6d1934e 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,28 @@ module github.com/unixshells/mosh-go go 1.26.0 require ( + github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff + github.com/charmbracelet/x/ansi v0.11.6 + github.com/charmbracelet/x/vt v0.0.0-20260305213658-fe36e8c10185 github.com/creack/pty v1.1.24 golang.org/x/crypto v0.49.0 golang.org/x/term v0.41.0 ) -require golang.org/x/sys v0.42.0 // indirect +require ( + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/go.sum b/go.sum index 869c560..a21bda2 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,43 @@ +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw= +github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/vt v0.0.0-20260305213658-fe36e8c10185 h1:KYS9lWBROkdkuyc0bdQuS8C86Me3OAgjUAKcB4zArlg= +github.com/charmbracelet/x/vt v0.0.0-20260305213658-fe36e8c10185/go.mod h1:2djWW5EeP5QlpztEYYbbyyzUyyxVD71mWVQguNISHcM= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= diff --git a/server.go b/server.go index 48eaabb..5a09d54 100644 --- a/server.go +++ b/server.go @@ -10,8 +10,10 @@ import ( "os" "os/exec" "sync" + "sync/atomic" "time" + "github.com/charmbracelet/x/vt" "github.com/creack/pty" ) @@ -27,8 +29,8 @@ const ( // Maximum payload per UDP datagram. maxPayload = 16384 - // Heartbeat / tick interval for the SSP send loop. - tickInterval = 20 * time.Millisecond + // Tick interval matches mosh's SEND_MINDELAY (8ms) for keystroke batching. + tickInterval = 8 * time.Millisecond // Server gives up waiting for client after this. associationTimeout = 60 * time.Second @@ -59,6 +61,12 @@ type Server struct { // Transport handles SSP sequencing, fragmentation, and crypto. transport *Transport + // VT emulator and framebuffer state for CUP-based diffing. + emu *vt.Emulator + baseFB *Framebuffer // what client has (last-acked) + sentFB *Framebuffer // what we last sent (pending ack) + curVisible atomic.Bool + // Remote client address — set on first received datagram. mu sync.Mutex clientAddr *net.UDPAddr @@ -154,6 +162,13 @@ func (s *Server) Serve() error { Cols: uint16(s.cols), }) + s.emu = vt.NewEmulator(s.cols, s.rows) + s.curVisible.Store(true) + s.emu.SetCallbacks(vt.Callbacks{ + CursorVisibility: func(visible bool) { s.curVisible.Store(visible) }, + }) + s.baseFB = NewFramebuffer(s.cols, s.rows) + var wg sync.WaitGroup wg.Add(3) @@ -196,6 +211,13 @@ func (s *Server) Done() <-chan struct{} { func (s *Server) ServeRW(rw io.ReadWriteCloser, resize func(cols, rows uint16)) error { close(s.started) + s.emu = vt.NewEmulator(s.cols, s.rows) + s.curVisible.Store(true) + s.emu.SetCallbacks(vt.Callbacks{ + CursorVisibility: func(visible bool) { s.curVisible.Store(visible) }, + }) + s.baseFB = NewFramebuffer(s.cols, s.rows) + var wg sync.WaitGroup wg.Add(3) @@ -265,7 +287,7 @@ func (s *Server) mainLoopRW(rw io.Writer, resize func(cols, rows uint16), ioOutp ticker := time.NewTicker(tickInterval) defer ticker.Stop() - var pending []byte + dirty := false for { select { @@ -276,7 +298,8 @@ func (s *Server) mainLoopRW(rw io.Writer, resize func(cols, rows uint16), ioOutp if !ok { return } - pending = append(pending, data...) + s.emu.Write(data) + dirty = true case ui := <-userInput: if len(ui.Keys) > 0 { @@ -290,18 +313,29 @@ func (s *Server) mainLoopRW(rw io.Writer, resize func(cols, rows uint16), ioOutp if resize != nil { resize(uint16(ui.Width), uint16(ui.Height)) } + s.emu.Resize(int(ui.Width), int(ui.Height)) + s.baseFB = NewFramebuffer(int(ui.Width), int(ui.Height)) + s.sentFB = nil + dirty = true } case <-ticker.C: - if len(pending) > 0 { - hi := HostInstruction{ - Hoststring: pending, - EchoAckNum: -1, - } - diff := marshalHostMessage([]HostInstruction{hi}) - s.transport.SetPending(diff) - pending = nil + if s.sentFB != nil && s.transport.AckedByRemote() >= s.transport.SentNum() { + s.baseFB = s.sentFB + s.sentFB = nil } + + if dirty { + currentFB := SnapshotEmulator(s.emu, s.curVisible.Load()) + diffBytes := currentFB.Diff(s.baseFB) + if len(diffBytes) > 0 { + hi := HostInstruction{Hoststring: diffBytes, EchoAckNum: -1} + s.transport.SetPending(marshalHostMessage([]HostInstruction{hi})) + s.sentFB = currentFB + } + dirty = false + } + datagrams := s.transport.Tick() s.sendDatagrams(datagrams) } @@ -331,8 +365,9 @@ func (s *Server) Close() { } } -// mainLoop is the SSP event loop. It maintains the terminal framebuffer, -// processes user input, diffs the screen, and drives the transport. +// mainLoop is the SSP event loop. It feeds PTY output to a VT emulator, +// snapshots the framebuffer, diffs against the last-acked state, and +// sends CUP-based updates via the transport. func (s *Server) mainLoop(ptyOutput <-chan []byte, userInput <-chan UserInstruction) { // Wait for client to connect before processing. for { @@ -349,14 +384,10 @@ func (s *Server) mainLoop(ptyOutput <-chan []byte, userInput <-chan UserInstruct } } - // Terminal state: we maintain a simple passthrough buffer. - // PTY output is accumulated and sent as HostBytes diffs. - // For full mosh compatibility, the diff is the raw terminal output - // wrapped in the HostMessage protobuf. ticker := time.NewTicker(tickInterval) defer ticker.Stop() - var pending []byte + dirty := false for { select { @@ -367,14 +398,13 @@ func (s *Server) mainLoop(ptyOutput <-chan []byte, userInput <-chan UserInstruct if !ok { return } - pending = append(pending, data...) + s.emu.Write(data) + dirty = true case ui := <-userInput: - // Handle keystrokes. if len(ui.Keys) > 0 { s.ptmx.Write(ui.Keys) } - // Handle resize. if ui.Width > 0 && ui.Height > 0 { s.mu.Lock() s.cols = int(ui.Width) @@ -384,20 +414,28 @@ func (s *Server) mainLoop(ptyOutput <-chan []byte, userInput <-chan UserInstruct Cols: uint16(ui.Width), Rows: uint16(ui.Height), }) + s.emu.Resize(int(ui.Width), int(ui.Height)) + s.baseFB = NewFramebuffer(int(ui.Width), int(ui.Height)) + s.sentFB = nil + dirty = true } case <-ticker.C: - // Build diff and send via transport. - if len(pending) > 0 { - hi := HostInstruction{ - Hoststring: pending, - EchoAckNum: -1, - } - diff := marshalHostMessage([]HostInstruction{hi}) + // Advance base when client acks all pending. + if s.sentFB != nil && s.transport.AckedByRemote() >= s.transport.SentNum() { + s.baseFB = s.sentFB + s.sentFB = nil + } - // Wrap in TransportInstruction via transport. - s.transport.SetPending(diff) - pending = nil + if dirty { + currentFB := SnapshotEmulator(s.emu, s.curVisible.Load()) + diffBytes := currentFB.Diff(s.baseFB) + if len(diffBytes) > 0 { + hi := HostInstruction{Hoststring: diffBytes, EchoAckNum: -1} + s.transport.SetPending(marshalHostMessage([]HostInstruction{hi})) + s.sentFB = currentFB + } + dirty = false } datagrams := s.transport.Tick() diff --git a/server_test.go b/server_test.go index 5e012af..8667ce3 100644 --- a/server_test.go +++ b/server_test.go @@ -8,6 +8,7 @@ import ( "net" "os" "os/exec" + "regexp" "strconv" "strings" "testing" @@ -16,6 +17,13 @@ import ( "github.com/creack/pty" ) +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;?]*[A-Za-z]`) + +// stripANSI removes CSI escape sequences from s. +func stripANSI(s string) string { + return ansiRe.ReplaceAllString(s, "") +} + func TestGenerateKey(t *testing.T) { key, b64, err := GenerateKey() if err != nil { @@ -175,13 +183,13 @@ func TestServerE2E(t *testing.T) { for !found { select { case <-deadline: - t.Fatalf("marker not echoed. Got: %q", allOutput) + t.Fatalf("marker not echoed. Got: %q", stripANSI(allOutput)) default: } payload := recvClient(500 * time.Millisecond) if payload != nil { allOutput += string(payload) - if strings.Contains(allOutput, marker) { + if strings.Contains(stripANSI(allOutput), marker) { found = true } } @@ -504,14 +512,14 @@ func TestServeRW(t *testing.T) { for { select { case <-deadline: - t.Fatalf("did not receive terminal output via ServeRW, got: %q", allOutput) + t.Fatalf("did not receive terminal output via ServeRW, got: %q", stripANSI(allOutput)) default: } payload := recvClient(500 * time.Millisecond) if payload != nil { allOutput += string(payload) } - if strings.Contains(allOutput, "HELLO_RW") { + if strings.Contains(stripANSI(allOutput), "HELLO_RW") { break } sendClient(nil) @@ -625,14 +633,14 @@ func TestServeRW(t *testing.T) { for { select { case <-deadline2: - t.Fatalf("did not receive terminal output, got: %q", allOutput2) + t.Fatalf("did not receive terminal output, got: %q", stripANSI(allOutput2)) default: } payload := recvClient2(500 * time.Millisecond) if payload != nil { allOutput2 += string(payload) } - if strings.Contains(allOutput2, "OUTPUT_OK") { + if strings.Contains(stripANSI(allOutput2), "OUTPUT_OK") { break } sendClient2(nil)