9 Commits
Author SHA1 Message Date
zhanglq e17b92cd9d 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
2026-07-02 21:01:24 +08:00
realrasengan 8dca5c67ec add video caption 2026-04-05 15:06:48 -07:00
realrasengan 94dd5a8b52 add demo thumbnail, fix dependencies list 2026-04-05 15:05:28 -07:00
realrasengan 31b4ffccfc cap state map to 16 entries to prevent OOM in WASM 2026-04-02 23:40:59 -07:00
realrasengan d9db3986ba reuse zlib writer to fix OOM in WASM 2026-04-02 21:29:51 -07:00
realrasengan 8e597f7e5d fix prediction: confirm space characters instead of stalling 2026-04-01 18:22:18 -07:00
realrasengan f058c421e7 WASM: support WebTransport reconnection with session token 2026-04-01 16:54:13 -07:00
realrasengan 4ccfea994f add local echo prediction engine, integrate into WASM client 2026-04-01 12:33:33 -07:00
realrasengan bde81c13f6 fix WASM OOM: reuse shadow emulator instead of allocating new ones on state miss 2026-03-31 21:07:22 -07:00
10 changed files with 848 additions and 33 deletions
+15 -1
View File
@@ -10,6 +10,9 @@ Implements the full mosh wire protocol: AES-128-OCB3 authenticated
encryption, SSP transport with sequencing and retransmission, datagram encryption, SSP transport with sequencing and retransmission, datagram
fragmentation, and the protobuf state sync layer. fragmentation, and the protobuf state sync layer.
[![Watch the demo](demo.png)](https://www.youtube.com/watch?v=iWo9I2bjZkI)
*[unixshells.com](https://unixshells.com) uses mosh-go compiled to WASM for the web terminal*
## Install ## Install
``` ```
@@ -95,6 +98,16 @@ Standard mosh clients and servers ignore them (forward-compatible).
- **Session control** (Host/UserInstruction field 9): list, switch, - **Session control** (Host/UserInstruction field 9): list, switch,
and create latch sessions over the mosh connection. and create latch sessions over the mosh connection.
## WASM build
The mosh client compiles to WebAssembly for use in the web terminal:
```
GOOS=js GOARCH=wasm CGO_ENABLED=0 go build -tags osusergo -o mosh.wasm ./cmd/mosh-wasm/
```
The `-tags osusergo` flag is required to avoid `os/user` CGO issues with `GOOS=js`.
## Files ## Files
- `server.go` -- server, PTY lifecycle, `ServeRW` bridge mode - `server.go` -- server, PTY lifecycle, `ServeRW` bridge mode
@@ -104,10 +117,11 @@ Standard mosh clients and servers ignore them (forward-compatible).
- `fragment.go` -- datagram fragmentation and reassembly - `fragment.go` -- datagram fragmentation and reassembly
- `pb.go` -- protobuf encoding (transport, host, user, latch extensions) - `pb.go` -- protobuf encoding (transport, host, user, latch extensions)
- `framebuffer.go` -- terminal state tracking for diff protocol - `framebuffer.go` -- terminal state tracking for diff protocol
- `predict.go` -- local echo prediction engine (speculative keystroke rendering)
## Dependencies ## Dependencies
`creack/pty` and the Go standard library. `creack/pty`, `golang.org/x/crypto`, and the Go standard library.
## License ## License
+18 -1
View File
@@ -260,6 +260,13 @@ func (c *Client) recvLoop() {
defer c.wg.Done() defer c.wg.Done()
buf := make([]byte, maxPayload+64) 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 { for {
select { select {
case <-c.done: case <-c.done:
@@ -287,6 +294,9 @@ func (c *Client) recvLoop() {
continue continue
} }
// Read oldNum AFTER transport.Recv() — it's set by the Recv call above.
currentOldNum := c.transport.LastRecvOldNum()
instrs, err := UnmarshalHostMessage(diff) instrs, err := UnmarshalHostMessage(diff)
if err != nil || len(instrs) == 0 { if err != nil || len(instrs) == 0 {
continue continue
@@ -301,7 +311,14 @@ func (c *Client) recvLoop() {
} }
c.mu.Lock() 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() c.mu.Unlock()
select { select {
+25 -2
View File
@@ -98,9 +98,15 @@ func (s *session) jsObject() js.Value {
if len(args) < 1 { if len(args) < 1 {
return nil return nil
} }
s.client.Send([]byte(args[0].String())) data := []byte(args[0].String())
s.client.Send(data)
s.client.Tick() s.client.Tick()
return nil
// Feed input to predictor — returns immediate overlay output.
if out := s.tracker.keystroke(data); len(out) > 0 {
return string(out)
}
return js.Null()
})) }))
obj.Set("resize", js.FuncOf(func(this js.Value, args []js.Value) interface{} { obj.Set("resize", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
@@ -130,6 +136,23 @@ func (s *session) jsObject() js.Value {
return string(out) return string(out)
})) }))
// reconnect() — swap WebTransport connection using session token.
obj.Set("reconnect", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
handler := js.FuncOf(func(this js.Value, pargs []js.Value) interface{} {
resolve := pargs[0]
rejectFn := pargs[1]
go func() {
if err := s.conn.Reconnect(); err != nil {
rejectFn.Invoke(err.Error())
return
}
resolve.Invoke(true)
}()
return nil
})
return js.Global().Get("Promise").New(handler)
}))
obj.Set("close", js.FuncOf(func(this js.Value, args []js.Value) interface{} { obj.Set("close", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
s.mu.Lock() s.mu.Lock()
s.closed = true s.closed = true
+97 -12
View File
@@ -4,6 +4,7 @@ package main
import ( import (
"sync" "sync"
"time"
mosh "github.com/unixshells/mosh-go" mosh "github.com/unixshells/mosh-go"
vt "github.com/unixshells/vt-go" vt "github.com/unixshells/vt-go"
@@ -27,16 +28,20 @@ type stateTracker struct {
latestState uint64 latestState uint64
displayedFB *mosh.Framebuffer displayedFB *mosh.Framebuffer
// Prediction engine for local echo.
predictor *mosh.Predictor
// Output buffer: pre-diffed ANSI ready for the terminal. // Output buffer: pre-diffed ANSI ready for the terminal.
output []byte output []byte
} }
func newStateTracker(cols, rows int) *stateTracker { func newStateTracker(cols, rows int) *stateTracker {
return &stateTracker{ return &stateTracker{
shadow: vt.NewEmulator(cols, rows), shadow: vt.NewEmulator(cols, rows),
cols: cols, cols: cols,
rows: rows, rows: rows,
states: make(map[uint64]*mosh.Framebuffer), states: make(map[uint64]*mosh.Framebuffer),
predictor: mosh.NewPredictor(),
} }
} }
@@ -61,15 +66,14 @@ func (st *stateTracker) applyDiff(diff []byte, oldNum, newNum, throwawayNum uint
// Need the base state to apply the diff. // Need the base state to apply the diff.
base, ok := st.states[oldNum] base, ok := st.states[oldNum]
if !ok { if !ok {
// After a resize we have no states. Start fresh with a // Unknown base state. Reset the shadow emulator and start fresh.
// clean emulator so the first post-resize diff works. st.resetShadow()
st.shadow = vt.NewEmulator(st.cols, st.rows)
st.shadowState = oldNum st.shadowState = oldNum
base = mosh.SnapshotEmulator(st.shadow, true) base = mosh.SnapshotEmulator(st.shadow, true)
st.states[oldNum] = base st.states[oldNum] = base
} else if st.shadowState != oldNum { } else if st.shadowState != oldNum {
st.shadow = vt.NewEmulator(st.cols, st.rows) // Restore shadow to the base state.
// Write the base framebuffer as ANSI to restore state. st.resetShadow()
if base != nil { if base != nil {
st.shadow.Write(base.Diff(nil)) st.shadow.Write(base.Diff(nil))
} }
@@ -103,14 +107,39 @@ func (st *stateTracker) applyDiff(diff []byte, oldNum, newNum, throwawayNum uint
} }
} }
// Display diff: compute ANSI difference between displayed and latest. // Hard cap: keep at most 16 states to prevent unbounded memory growth.
if len(st.states) > 16 {
minKeep := st.latestState
if minKeep > 16 {
minKeep -= 16
} else {
minKeep = 0
}
for n := range st.states {
if n < minKeep {
delete(st.states, n)
}
}
}
// Confirm predictions against server state.
latest := st.states[st.latestState] latest := st.states[st.latestState]
if latest != nil { if latest != nil {
ansi := latest.Diff(st.displayedFB) st.predictor.Confirm(latest)
st.predictor.ExpireStale(time.Now())
// Apply prediction overlay for display.
displayFB := latest
if st.predictor.Active() {
displayFB = st.cloneFB(latest)
st.predictor.Overlay(displayFB)
}
ansi := displayFB.Diff(st.displayedFB)
if len(ansi) > 0 { if len(ansi) > 0 {
st.output = append(st.output, ansi...) st.output = append(st.output, ansi...)
} }
st.displayedFB = latest st.displayedFB = displayFB
} }
} }
@@ -126,15 +155,71 @@ func (st *stateTracker) poll() []byte {
return out return out
} }
// keystroke feeds user input to the predictor and returns any prediction
// overlay output that should be displayed immediately.
func (st *stateTracker) keystroke(data []byte) []byte {
st.mu.Lock()
defer st.mu.Unlock()
st.predictor.Keystroke(data)
if !st.predictor.Active() || st.displayedFB == nil {
return nil
}
// Overlay predictions onto the current display and diff.
overlaid := st.cloneFB(st.displayedFB)
// First undo previous prediction overlay by using latest server state.
var serverFB *mosh.Framebuffer
if st.latestState > 0 {
serverFB = st.states[st.latestState]
}
if serverFB == nil {
serverFB = st.displayedFB
}
displayFB := st.cloneFB(serverFB)
st.predictor.Overlay(displayFB)
ansi := displayFB.Diff(overlaid)
if len(ansi) > 0 {
st.displayedFB = displayFB
}
return ansi
}
// cloneFB creates a deep copy of a framebuffer.
func (st *stateTracker) cloneFB(fb *mosh.Framebuffer) *mosh.Framebuffer {
clone := &mosh.Framebuffer{
W: fb.W,
H: fb.H,
CurX: fb.CurX,
CurY: fb.CurY,
CurVis: fb.CurVis,
Cells: make([]mosh.Cell, len(fb.Cells)),
}
copy(clone.Cells, fb.Cells)
return clone
}
// resize resets the state tracker for new dimensions. // resize resets the state tracker for new dimensions.
func (st *stateTracker) resize(cols, rows int) { func (st *stateTracker) resize(cols, rows int) {
st.mu.Lock() st.mu.Lock()
defer st.mu.Unlock() defer st.mu.Unlock()
st.cols = cols st.cols = cols
st.rows = rows st.rows = rows
// Must create a new emulator for new dimensions.
st.shadow = vt.NewEmulator(cols, rows) st.shadow = vt.NewEmulator(cols, rows)
st.shadowState = 0 st.shadowState = 0
st.states = make(map[uint64]*mosh.Framebuffer) st.states = make(map[uint64]*mosh.Framebuffer)
st.displayedFB = nil st.displayedFB = nil
st.predictor.Reset()
}
// resetShadow clears the shadow emulator without reallocating it.
func (st *stateTracker) resetShadow() {
st.shadow.Resize(st.cols, st.rows)
st.shadow.Write([]byte("\033[H\033[2J\033[m"))
} }
+122 -7
View File
@@ -4,21 +4,27 @@ package main
import ( import (
"errors" "errors"
"strings"
"sync" "sync"
"syscall/js" "syscall/js"
"time" "time"
) )
// wtConn wraps a browser WebTransport connection as a mosh.Conn. // wtConn wraps a browser WebTransport connection as a mosh.Conn.
// Supports transparent reconnection: when the underlying WebTransport
// dies, reads return timeouts while the worker re-dials. Once a new
// connection is swapped in via Reconnect(), reads resume.
type wtConn struct { type wtConn struct {
mu sync.Mutex
transport js.Value transport js.Value
writer js.Value // persistent writable stream writer writer js.Value // persistent writable stream writer
incoming chan []byte incoming chan []byte
done chan struct{} done chan struct{}
once sync.Once once sync.Once
deadline time.Time
mu sync.Mutex url string
deadline time.Time token string // session token from relay, used for reconnect
} }
func dialWebTransport(url string) (*wtConn, error) { func dialWebTransport(url string) (*wtConn, error) {
@@ -64,6 +70,7 @@ func dialWebTransport(url string) (*wtConn, error) {
writer: writer, writer: writer,
incoming: make(chan []byte, 256), incoming: make(chan []byte, 256),
done: make(chan struct{}), done: make(chan struct{}),
url: url,
} }
// Start reading datagrams via a persistent JS callback. // Start reading datagrams via a persistent JS callback.
@@ -72,9 +79,94 @@ func dialWebTransport(url string) (*wtConn, error) {
return c, nil return c, nil
} }
// Reconnect dials a new WebTransport connection using the session token
// and swaps it into this conn. The mosh client keeps its SSP state and
// resumes transparently.
func (c *wtConn) Reconnect() error {
c.mu.Lock()
token := c.token
url := c.url
c.mu.Unlock()
if token == "" {
return errors.New("no session token")
}
// Add token to URL for reconnect.
sep := "&"
if !strings.Contains(url, "?") {
sep = "?"
}
reconnURL := url + sep + "token=" + token
wt := js.Global().Get("WebTransport").New(reconnURL)
readyCh := make(chan error, 1)
onReady := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
readyCh <- nil
return nil
})
onFail := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
msg := "reconnect failed"
if len(args) > 0 && !args[0].IsUndefined() && !args[0].IsNull() {
if m := args[0].Get("message"); !m.IsUndefined() {
msg = m.String()
}
}
readyCh <- errors.New(msg)
return nil
})
wt.Get("ready").Call("then", onReady, onFail)
select {
case err := <-readyCh:
if err != nil {
onReady.Release()
onFail.Release()
return err
}
case <-time.After(10 * time.Second):
onReady.Release()
onFail.Release()
wt.Call("close")
return errors.New("reconnect timeout")
}
onReady.Release()
onFail.Release()
writer := wt.Get("datagrams").Get("writable").Call("getWriter")
// Swap in the new connection.
c.mu.Lock()
oldTransport := c.transport
c.transport = wt
c.writer = writer
c.mu.Unlock()
// Close old transport (best-effort).
if !oldTransport.IsUndefined() && !oldTransport.IsNull() {
defer func() { recover() }()
oldTransport.Call("close")
}
// Start reading from the new connection.
c.startReader()
return nil
}
// Token returns the session token received from the relay.
func (c *wtConn) Token() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.token
}
// startReader sets up a self-chaining read loop using persistent callbacks. // startReader sets up a self-chaining read loop using persistent callbacks.
func (c *wtConn) startReader() { func (c *wtConn) startReader() {
c.mu.Lock()
reader := c.transport.Get("datagrams").Get("readable").Call("getReader") reader := c.transport.Get("datagrams").Get("readable").Call("getReader")
c.mu.Unlock()
var onData, onErr js.Func var onData, onErr js.Func
var readNext js.Func var readNext js.Func
@@ -92,10 +184,19 @@ func (c *wtConn) startReader() {
value := result.Get("value") value := result.Get("value")
buf := make([]byte, value.Get("byteLength").Int()) buf := make([]byte, value.Get("byteLength").Int())
js.CopyBytesToGo(buf, js.Global().Get("Uint8Array").New(value)) js.CopyBytesToGo(buf, js.Global().Get("Uint8Array").New(value))
select {
case c.incoming <- buf: // Check for TOKEN: prefix (session token from relay).
default: if len(buf) > 6 && string(buf[:6]) == "TOKEN:" {
c.mu.Lock()
c.token = string(buf[6:])
c.mu.Unlock()
} else {
select {
case c.incoming <- buf:
default:
}
} }
// Defer next read to next event loop tick so Go goroutines can process. // Defer next read to next event loop tick so Go goroutines can process.
js.Global().Call("setTimeout", readNext, 0) js.Global().Call("setTimeout", readNext, 0)
return nil return nil
@@ -103,6 +204,7 @@ func (c *wtConn) startReader() {
onErr = js.FuncOf(func(this js.Value, args []js.Value) interface{} { onErr = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
// Stream error or closed — stop reading. // Stream error or closed — stop reading.
// The reconnect logic in the worker will handle this.
return nil return nil
}) })
@@ -136,9 +238,17 @@ func (c *wtConn) Read(b []byte) (int, error) {
} }
func (c *wtConn) Write(b []byte) (int, error) { func (c *wtConn) Write(b []byte) (int, error) {
c.mu.Lock()
writer := c.writer
c.mu.Unlock()
if writer.IsUndefined() || writer.IsNull() {
return 0, errors.New("not connected")
}
arr := js.Global().Get("Uint8Array").New(len(b)) arr := js.Global().Get("Uint8Array").New(len(b))
js.CopyBytesToJS(arr, b) js.CopyBytesToJS(arr, b)
c.writer.Call("write", arr) writer.Call("write", arr)
return len(b), nil return len(b), nil
} }
@@ -152,7 +262,12 @@ func (c *wtConn) SetReadDeadline(t time.Time) error {
func (c *wtConn) Close() error { func (c *wtConn) Close() error {
c.once.Do(func() { c.once.Do(func() {
close(c.done) close(c.done)
c.transport.Call("close") c.mu.Lock()
t := c.transport
c.mu.Unlock()
if !t.IsUndefined() && !t.IsNull() {
t.Call("close")
}
}) })
return nil return nil
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

+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 go 1.26.0
+207
View File
@@ -0,0 +1,207 @@
package mosh
import (
"time"
"unicode"
"unicode/utf8"
)
// Predictor implements mosh-style speculative local echo.
//
// When the user types a printable character, it is predicted to appear
// at the current cursor position and the cursor advances. When the
// server confirms the character (it appears in the server's framebuffer
// at the expected position), the prediction is retired. If the server
// diverges, all predictions are cleared.
//
// This is the core of what makes mosh feel local: keystrokes appear
// instantly even over high-latency connections.
type Predictor struct {
pending []prediction // unconfirmed predictions, in order typed
curX int // predicted cursor X (columns)
curY int // predicted cursor Y (rows)
epoch int // incremented on reset; stale predictions are ignored
active bool // false until first prediction; cleared on divergence
// Glitch: when the server confirms a prediction, we note it so the
// overlay can stop rendering that cell as "speculative".
confirmed int // count of predictions confirmed so far this epoch
}
type prediction struct {
r rune
x, y int // where we predicted it would land
epoch int
at time.Time
}
const (
predictionTimeout = 500 * time.Millisecond
)
// NewPredictor creates a new predictor.
func NewPredictor() *Predictor {
return &Predictor{}
}
// KeystrokeFrom processes user input and returns any printable runes
// that should be predicted. Non-printable input (control chars, escape
// sequences) clears predictions since we can't know what the server
// will do.
func (p *Predictor) Keystroke(input []byte) {
i := 0
for i < len(input) {
r, size := utf8.DecodeRune(input[i:])
i += size
if r == utf8.RuneError {
p.Reset()
return
}
if r < 0x20 || r == 0x7f {
// Control character — we can't predict what happens.
// Backspace, enter, tab, escape sequences, etc.
p.Reset()
return
}
if unicode.IsPrint(r) {
p.pending = append(p.pending, prediction{
r: r,
x: p.curX,
y: p.curY,
epoch: p.epoch,
at: time.Now(),
})
p.curX++
p.active = true
}
}
}
// Reset clears all predictions (e.g., on control characters or server divergence).
func (p *Predictor) Reset() {
p.pending = p.pending[:0]
p.epoch++
p.active = false
p.confirmed = 0
}
// SetCursor updates the predicted cursor position from the server state.
// Call this when processing server output so predictions know where
// the cursor is.
func (p *Predictor) SetCursor(x, y int) {
if !p.active {
p.curX = x
p.curY = y
}
}
// Active returns true if there are pending predictions.
func (p *Predictor) Active() bool {
return p.active && len(p.pending) > 0
}
// ExpireStale removes predictions older than the timeout.
func (p *Predictor) ExpireStale(now time.Time) {
cutoff := now.Add(-predictionTimeout)
changed := false
for len(p.pending) > 0 && p.pending[0].at.Before(cutoff) {
p.pending = p.pending[1:]
changed = true
}
if changed && len(p.pending) == 0 {
p.active = false
}
}
// Confirm checks the server framebuffer against pending predictions.
// Confirmed predictions are removed. If the server shows something
// different where we predicted, all predictions are cleared.
func (p *Predictor) Confirm(fb *Framebuffer) {
if !p.active || len(p.pending) == 0 {
// No predictions, just track cursor.
p.curX = fb.CurX
p.curY = fb.CurY
return
}
// Walk through pending predictions and see if the server matches.
confirmed := 0
for confirmed < len(p.pending) {
pred := &p.pending[confirmed]
if pred.epoch != p.epoch {
// Stale prediction from before a reset.
confirmed++
continue
}
cell := fb.CellAt(pred.x, pred.y)
if cell == nil {
// Out of bounds — clear everything.
p.Reset()
p.curX = fb.CurX
p.curY = fb.CurY
return
}
if cell.Rune == pred.r {
// Server confirmed this prediction.
confirmed++
} else if (cell.Rune == ' ' || cell.Rune == 0) && pred.r != ' ' {
// Server hasn't caught up yet — stop checking.
// (But if we predicted a space, a space cell is a match, handled above.)
break
} else {
// Server diverged — something unexpected happened
// (tab completion, shell editing, etc.)
p.Reset()
p.curX = fb.CurX
p.curY = fb.CurY
return
}
}
if confirmed > 0 {
p.pending = p.pending[confirmed:]
p.confirmed += confirmed
}
if len(p.pending) == 0 {
// All predictions confirmed. Sync cursor to server.
p.active = false
p.curX = fb.CurX
p.curY = fb.CurY
}
}
// Overlay applies pending predictions onto a framebuffer for display.
// The framebuffer is modified in-place — call this on a copy if needed.
// Predicted characters get an underline attribute to indicate they're
// speculative (matching C++ mosh behavior).
func (p *Predictor) Overlay(fb *Framebuffer) {
if !p.active {
return
}
for i := range p.pending {
pred := &p.pending[i]
if pred.epoch != p.epoch {
continue
}
cell := fb.CellAt(pred.x, pred.y)
if cell == nil {
continue
}
cell.Rune = pred.r
cell.Width = 1
cell.Attr.Under = true // underline marks speculative characters
}
// Move cursor to predicted position.
if len(p.pending) > 0 {
fb.CurX = p.curX
fb.CurY = p.curY
}
}
+357
View File
@@ -0,0 +1,357 @@
package mosh
import (
"testing"
"time"
"unicode/utf8"
)
func TestPredictorBasicEcho(t *testing.T) {
p := NewPredictor()
p.SetCursor(0, 0)
// Type "abc"
p.Keystroke([]byte("a"))
p.Keystroke([]byte("b"))
p.Keystroke([]byte("c"))
if !p.Active() {
t.Fatal("predictor should be active after typing")
}
if len(p.pending) != 3 {
t.Fatalf("expected 3 pending, got %d", len(p.pending))
}
if p.pending[0].r != 'a' || p.pending[0].x != 0 || p.pending[0].y != 0 {
t.Fatalf("prediction 0: got %+v", p.pending[0])
}
if p.pending[1].r != 'b' || p.pending[1].x != 1 {
t.Fatalf("prediction 1: got %+v", p.pending[1])
}
if p.pending[2].r != 'c' || p.pending[2].x != 2 {
t.Fatalf("prediction 2: got %+v", p.pending[2])
}
}
func TestPredictorOverlay(t *testing.T) {
p := NewPredictor()
p.SetCursor(0, 0)
p.Keystroke([]byte("hi"))
fb := NewFramebuffer(80, 24)
p.Overlay(fb)
c0 := fb.CellAt(0, 0)
c1 := fb.CellAt(1, 0)
if c0.Rune != 'h' || !c0.Attr.Under {
t.Fatalf("cell 0: got %+v, want 'h' underlined", c0)
}
if c1.Rune != 'i' || !c1.Attr.Under {
t.Fatalf("cell 1: got %+v, want 'i' underlined", c1)
}
if fb.CurX != 2 || fb.CurY != 0 {
t.Fatalf("cursor: got (%d,%d), want (2,0)", fb.CurX, fb.CurY)
}
}
func TestPredictorConfirmAll(t *testing.T) {
p := NewPredictor()
p.SetCursor(0, 0)
p.Keystroke([]byte("ab"))
// Simulate server confirming "ab" at positions 0,1
fb := NewFramebuffer(80, 24)
fb.CellAt(0, 0).Rune = 'a'
fb.CellAt(1, 0).Rune = 'b'
fb.CurX = 2
fb.CurY = 0
p.Confirm(fb)
if p.Active() {
t.Fatal("predictor should be inactive after full confirmation")
}
if len(p.pending) != 0 {
t.Fatalf("expected 0 pending, got %d", len(p.pending))
}
}
func TestPredictorPartialConfirm(t *testing.T) {
p := NewPredictor()
p.SetCursor(0, 0)
p.Keystroke([]byte("abc"))
// Server has confirmed "a" but not "b" or "c" yet
fb := NewFramebuffer(80, 24)
fb.CellAt(0, 0).Rune = 'a'
fb.CurX = 1
fb.CurY = 0
p.Confirm(fb)
if !p.Active() {
t.Fatal("predictor should still be active with pending predictions")
}
if len(p.pending) != 2 {
t.Fatalf("expected 2 pending, got %d", len(p.pending))
}
if p.pending[0].r != 'b' {
t.Fatalf("first remaining prediction: got %c, want b", p.pending[0].r)
}
}
func TestPredictorDivergence(t *testing.T) {
p := NewPredictor()
p.SetCursor(0, 0)
p.Keystroke([]byte("abc"))
// Server shows 'x' where we predicted 'a' — tab completion, etc.
fb := NewFramebuffer(80, 24)
fb.CellAt(0, 0).Rune = 'x'
fb.CurX = 5
fb.CurY = 0
p.Confirm(fb)
if p.Active() {
t.Fatal("predictor should reset on divergence")
}
if p.curX != 5 {
t.Fatalf("cursor should sync to server: got %d, want 5", p.curX)
}
}
func TestPredictorControlCharResets(t *testing.T) {
p := NewPredictor()
p.SetCursor(0, 0)
p.Keystroke([]byte("ab"))
if !p.Active() {
t.Fatal("should be active")
}
// Send a control character (enter)
p.Keystroke([]byte("\n"))
if p.Active() {
t.Fatal("control char should reset predictions")
}
}
func TestPredictorEscapeSequenceResets(t *testing.T) {
p := NewPredictor()
p.SetCursor(0, 0)
p.Keystroke([]byte("ab"))
// Escape byte
p.Keystroke([]byte{0x1b})
if p.Active() {
t.Fatal("escape should reset predictions")
}
}
func TestPredictorExpireStale(t *testing.T) {
p := NewPredictor()
p.SetCursor(0, 0)
p.Keystroke([]byte("a"))
// Manually backdate the prediction
p.pending[0].at = time.Now().Add(-600 * time.Millisecond)
p.ExpireStale(time.Now())
if p.Active() {
t.Fatal("stale prediction should be expired")
}
if len(p.pending) != 0 {
t.Fatalf("expected 0 pending, got %d", len(p.pending))
}
}
func TestPredictorSpaceConfirm(t *testing.T) {
p := NewPredictor()
p.SetCursor(0, 0)
p.Keystroke([]byte("hi there"))
if len(p.pending) != 8 {
t.Fatalf("expected 8 predictions, got %d", len(p.pending))
}
// Server shows "hi " — space at position 2 should confirm, not stall
fb := NewFramebuffer(80, 24)
fb.CellAt(0, 0).Rune = 'h'
fb.CellAt(1, 0).Rune = 'i'
fb.CellAt(2, 0).Rune = ' '
fb.CurX = 3
fb.CurY = 0
p.Confirm(fb)
if len(p.pending) != 5 {
t.Fatalf("expected 5 pending after confirming 'hi ', got %d", len(p.pending))
}
if p.pending[0].r != 't' {
t.Fatalf("first remaining should be 't', got %c", p.pending[0].r)
}
}
func TestPredictorNoActivityNoCursorOverride(t *testing.T) {
p := NewPredictor()
// Without any predictions, SetCursor should track server
p.SetCursor(10, 5)
if p.curX != 10 || p.curY != 5 {
t.Fatalf("cursor not tracking: got (%d,%d)", p.curX, p.curY)
}
// After typing, SetCursor should not override predicted position
p.Keystroke([]byte("x"))
p.SetCursor(0, 0)
if p.curX != 11 {
t.Fatalf("predicted cursor should not be overridden: got %d, want 11", p.curX)
}
}
func TestPredictorMultibyteUTF8(t *testing.T) {
p := NewPredictor()
p.SetCursor(0, 0)
p.Keystroke([]byte("é")) // 2 bytes
if len(p.pending) != 1 {
t.Fatalf("expected 1 prediction, got %d", len(p.pending))
}
if p.pending[0].r != 'é' {
t.Fatalf("got %c, want é", p.pending[0].r)
}
}
func TestPredictorOverlayDoesNotAffectUnpredictedCells(t *testing.T) {
p := NewPredictor()
p.SetCursor(5, 0)
p.Keystroke([]byte("x"))
fb := NewFramebuffer(80, 24)
fb.CellAt(0, 0).Rune = 'A'
fb.CellAt(1, 0).Rune = 'B'
p.Overlay(fb)
// Unpredicted cells should be untouched
if fb.CellAt(0, 0).Rune != 'A' {
t.Fatal("cell 0 should be unchanged")
}
if fb.CellAt(1, 0).Rune != 'B' {
t.Fatal("cell 1 should be unchanged")
}
// Predicted cell
if fb.CellAt(5, 0).Rune != 'x' {
t.Fatal("cell 5 should be 'x'")
}
}
// TestPredictorE2EGoServer tests prediction with real mosh transport.
func TestPredictorE2EGoServer(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
}
}
pred := NewPredictor()
pred.SetCursor(0, 0)
// Type printable chars — predictions should appear immediately.
keys := []byte("echo hello")
pred.Keystroke(keys)
client.Send(keys)
if !pred.Active() {
t.Fatal("predictor should be active after typing")
}
if len(pred.pending) != 10 {
t.Fatalf("expected 10 predictions, got %d", len(pred.pending))
}
// The predictions can be overlaid on a framebuffer immediately —
// this is the "instant echo" the user sees.
fb := NewFramebuffer(80, 24)
pred.Overlay(fb)
if fb.CellAt(0, 0).Rune != 'e' || fb.CellAt(1, 0).Rune != 'c' {
t.Fatal("overlay should show predicted characters immediately")
}
// Wait for server to confirm. The server will echo the characters
// and predictions should get confirmed.
deadline := time.After(10 * time.Second)
for pred.Active() {
select {
case <-deadline:
t.Fatalf("predictions not confirmed. %d still pending", len(pred.pending))
default:
}
out := client.Recv(200 * time.Millisecond)
if out != nil {
// Parse server output into a framebuffer to confirm predictions.
// For simplicity, build a minimal framebuffer from ANSI output.
serverFB := NewFramebuffer(80, 24)
applyANSIToFB(serverFB, out)
pred.Confirm(serverFB)
}
pred.ExpireStale(time.Now())
}
t.Log("prediction E2E with Go server passed")
}
// applyANSIToFB is a minimal helper that writes characters from ANSI
// output into a framebuffer. It doesn't parse escape sequences — just
// extracts printable characters at sequential positions. Good enough
// for testing prediction confirmation.
func applyANSIToFB(fb *Framebuffer, data []byte) {
x, y := 0, 0
i := 0
for i < len(data) {
if data[i] == 0x1b {
// Skip escape sequence
i++
if i < len(data) && data[i] == '[' {
i++
for i < len(data) && !((data[i] >= 'A' && data[i] <= 'Z') || (data[i] >= 'a' && data[i] <= 'z')) {
i++
}
if i < len(data) {
i++ // skip final byte
}
}
continue
}
if data[i] == '\r' {
x = 0
i++
continue
}
if data[i] == '\n' {
y++
i++
continue
}
r, size := utf8.DecodeRune(data[i:])
i += size
if r >= 0x20 && x < fb.W && y < fb.H {
cell := fb.CellAt(x, y)
cell.Rune = r
cell.Width = 1
x++
}
}
fb.CurX = x
fb.CurY = y
}
+6 -9
View File
@@ -304,15 +304,12 @@ func (t *Transport) Recv(wire []byte) []byte {
} }
} }
// Check if we have old_num (required to apply diff). // Reject diffs that do not chain from our latest acknowledged state.
hasOld := false // Mosh diffs MUST be sequential: each diff's oldNum must equal the
for _, n := range t.receivedNums { // newNum of the most recently accepted diff. Accepting diffs from
if n == ti.OldNum { // stale bases (oldNum != ackNum) causes overlapping ANSI sequences
hasOld = true // that produce character ghosting and garbled output.
break if ti.OldNum != t.ackNum {
}
}
if !hasOld {
return nil return nil
} }