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.
This commit is contained in:
realrasengan
2026-03-14 20:34:30 -07:00
parent bb08f71e30
commit b8b5994527
6 changed files with 296 additions and 44 deletions
+76 -5
View File
@@ -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") {
+80
View File
@@ -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)}
}
}
+20 -1
View File
@@ -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
)
+36
View File
@@ -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=
+70 -32
View File
@@ -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()
+14 -6
View File
@@ -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)