fix: prevent Mosh character ghosting caused by overlapping diffs

- client.go: track last output oldNum, replace instead of append when same base
- transport.go: reject diffs that do not chain from latest acknowledged state
  (oldNum != ackNum), preventing stale ANSI sequences from corrupting output
This commit is contained in:
zhanglq
2026-07-02 21:01:24 +08:00
parent 8dca5c67ec
commit e17b92cd9d
3 changed files with 33 additions and 29 deletions
+18 -1
View File
@@ -260,6 +260,13 @@ func (c *Client) recvLoop() {
defer c.wg.Done()
buf := make([]byte, maxPayload+64)
// Track the base state (oldNum) of the most recently buffered output.
// Mosh diffs are cumulative snapshots from a given base. When a new diff
// has the same oldNum as the previous one, it supersedes the older diff
// and we replace the output buffer instead of appending. This prevents
// character ghosting caused by concatenating overlapping diffs.
var lastOutputOldNum uint64
for {
select {
case <-c.done:
@@ -287,6 +294,9 @@ func (c *Client) recvLoop() {
continue
}
// Read oldNum AFTER transport.Recv() — it's set by the Recv call above.
currentOldNum := c.transport.LastRecvOldNum()
instrs, err := UnmarshalHostMessage(diff)
if err != nil || len(instrs) == 0 {
continue
@@ -301,7 +311,14 @@ func (c *Client) recvLoop() {
}
c.mu.Lock()
c.output = append(c.output, output...)
// Same base → newer diff supersedes older one (replace).
// Different base → new incremental content (append).
if currentOldNum == lastOutputOldNum {
c.output = output
} else {
c.output = append(c.output, output...)
}
lastOutputOldNum = currentOldNum
c.mu.Unlock()
select {
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/unixshells/mosh-go
module github.com/ys-ll/mosh-go
go 1.26.0
+14 -27
View File
@@ -63,10 +63,6 @@ type Transport struct {
// Latch capability negotiation.
localCaps []byte
remoteCaps []byte
// Reusable zlib writer to avoid per-tick allocations.
zlibBuf bytes.Buffer
zlibW *zlib.Writer
}
const (
@@ -187,7 +183,7 @@ func (t *Transport) Tick() [][]byte {
// Marshal → compress → fragment → encrypt.
pbData := ti.Marshal()
compressed := t.zlibCompress(pbData)
compressed := zlibCompress(pbData)
frags := Fragmentize(t.sentNum, compressed)
var datagrams [][]byte
@@ -308,15 +304,12 @@ func (t *Transport) Recv(wire []byte) []byte {
}
}
// Check if we have old_num (required to apply diff).
hasOld := false
for _, n := range t.receivedNums {
if n == ti.OldNum {
hasOld = true
break
}
}
if !hasOld {
// Reject diffs that do not chain from our latest acknowledged state.
// Mosh diffs MUST be sequential: each diff's oldNum must equal the
// newNum of the most recently accepted diff. Accepting diffs from
// stale bases (oldNum != ackNum) causes overlapping ANSI sequences
// that produce character ghosting and garbled output.
if ti.OldNum != t.ackNum {
return nil
}
@@ -473,19 +466,13 @@ func (t *Transport) updateRTT(tsReply uint16) {
}
}
// zlibCompress compresses data with zlib, reusing the writer.
func (t *Transport) zlibCompress(data []byte) []byte {
t.zlibBuf.Reset()
if t.zlibW == nil {
t.zlibW = zlib.NewWriter(&t.zlibBuf)
} else {
t.zlibW.Reset(&t.zlibBuf)
}
t.zlibW.Write(data)
t.zlibW.Close()
out := make([]byte, t.zlibBuf.Len())
copy(out, t.zlibBuf.Bytes())
return out
// zlibCompress compresses data with zlib (default level).
func zlibCompress(data []byte) []byte {
var buf bytes.Buffer
w := zlib.NewWriter(&buf)
w.Write(data)
w.Close()
return buf.Bytes()
}
// zlibDecompress decompresses zlib data. Returns nil on error.