10 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
realrasengan 0284bd652a revert vt-go to v0.1.0 (v0.2.0 scrollback changes break mosh) 2026-03-31 19:19:02 -07:00
11 changed files with 851 additions and 36 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
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
```
@@ -95,6 +98,16 @@ Standard mosh clients and servers ignore them (forward-compatible).
- **Session control** (Host/UserInstruction field 9): list, switch,
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
- `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
- `pb.go` -- protobuf encoding (transport, host, user, latch extensions)
- `framebuffer.go` -- terminal state tracking for diff protocol
- `predict.go` -- local echo prediction engine (speculative keystroke rendering)
## Dependencies
`creack/pty` and the Go standard library.
`creack/pty`, `golang.org/x/crypto`, and the Go standard library.
## License
+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 {
+25 -2
View File
@@ -98,9 +98,15 @@ func (s *session) jsObject() js.Value {
if len(args) < 1 {
return nil
}
s.client.Send([]byte(args[0].String()))
data := []byte(args[0].String())
s.client.Send(data)
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{} {
@@ -130,6 +136,23 @@ func (s *session) jsObject() js.Value {
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{} {
s.mu.Lock()
s.closed = true
+97 -12
View File
@@ -4,6 +4,7 @@ package main
import (
"sync"
"time"
mosh "github.com/unixshells/mosh-go"
vt "github.com/unixshells/vt-go"
@@ -27,16 +28,20 @@ type stateTracker struct {
latestState uint64
displayedFB *mosh.Framebuffer
// Prediction engine for local echo.
predictor *mosh.Predictor
// Output buffer: pre-diffed ANSI ready for the terminal.
output []byte
}
func newStateTracker(cols, rows int) *stateTracker {
return &stateTracker{
shadow: vt.NewEmulator(cols, rows),
cols: cols,
rows: rows,
states: make(map[uint64]*mosh.Framebuffer),
shadow: vt.NewEmulator(cols, rows),
cols: cols,
rows: rows,
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.
base, ok := st.states[oldNum]
if !ok {
// After a resize we have no states. Start fresh with a
// clean emulator so the first post-resize diff works.
st.shadow = vt.NewEmulator(st.cols, st.rows)
// Unknown base state. Reset the shadow emulator and start fresh.
st.resetShadow()
st.shadowState = oldNum
base = mosh.SnapshotEmulator(st.shadow, true)
st.states[oldNum] = base
} else if st.shadowState != oldNum {
st.shadow = vt.NewEmulator(st.cols, st.rows)
// Write the base framebuffer as ANSI to restore state.
// Restore shadow to the base state.
st.resetShadow()
if base != 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]
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 {
st.output = append(st.output, ansi...)
}
st.displayedFB = latest
st.displayedFB = displayFB
}
}
@@ -126,15 +155,71 @@ func (st *stateTracker) poll() []byte {
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.
func (st *stateTracker) resize(cols, rows int) {
st.mu.Lock()
defer st.mu.Unlock()
st.cols = cols
st.rows = rows
// Must create a new emulator for new dimensions.
st.shadow = vt.NewEmulator(cols, rows)
st.shadowState = 0
st.states = make(map[uint64]*mosh.Framebuffer)
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 (
"errors"
"strings"
"sync"
"syscall/js"
"time"
)
// 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 {
mu sync.Mutex
transport js.Value
writer js.Value // persistent writable stream writer
incoming chan []byte
done chan struct{}
once sync.Once
deadline time.Time
mu sync.Mutex
deadline time.Time
url string
token string // session token from relay, used for reconnect
}
func dialWebTransport(url string) (*wtConn, error) {
@@ -64,6 +70,7 @@ func dialWebTransport(url string) (*wtConn, error) {
writer: writer,
incoming: make(chan []byte, 256),
done: make(chan struct{}),
url: url,
}
// Start reading datagrams via a persistent JS callback.
@@ -72,9 +79,94 @@ func dialWebTransport(url string) (*wtConn, error) {
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.
func (c *wtConn) startReader() {
c.mu.Lock()
reader := c.transport.Get("datagrams").Get("readable").Call("getReader")
c.mu.Unlock()
var onData, onErr js.Func
var readNext js.Func
@@ -92,10 +184,19 @@ func (c *wtConn) startReader() {
value := result.Get("value")
buf := make([]byte, value.Get("byteLength").Int())
js.CopyBytesToGo(buf, js.Global().Get("Uint8Array").New(value))
select {
case c.incoming <- buf:
default:
// Check for TOKEN: prefix (session token from relay).
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.
js.Global().Call("setTimeout", readNext, 0)
return nil
@@ -103,6 +204,7 @@ func (c *wtConn) startReader() {
onErr = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
// Stream error or closed — stop reading.
// The reconnect logic in the worker will handle this.
return nil
})
@@ -136,9 +238,17 @@ func (c *wtConn) Read(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))
js.CopyBytesToJS(arr, b)
c.writer.Call("write", arr)
writer.Call("write", arr)
return len(b), nil
}
@@ -152,7 +262,12 @@ func (c *wtConn) SetReadDeadline(t time.Time) error {
func (c *wtConn) Close() error {
c.once.Do(func() {
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
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

+2 -2
View File
@@ -1,11 +1,11 @@
module github.com/unixshells/mosh-go
module github.com/ys-ll/mosh-go
go 1.26.0
require (
github.com/charmbracelet/x/ansi v0.11.6
github.com/creack/pty v1.1.24
github.com/unixshells/vt-go v0.2.0
github.com/unixshells/vt-go v0.1.0
golang.org/x/crypto v0.49.0
golang.org/x/term v0.41.0
)
+2 -2
View File
@@ -28,8 +28,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
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/unixshells/vt-go v0.2.0 h1:fKOHymso7q4mE/TTRPBZMWtM5kZFoHdPUsk1kkLtypA=
github.com/unixshells/vt-go v0.2.0/go.mod h1:dZpOXxVyO7LG1uxkVMixX7HwUf2ZbUkEKhtHHJTTIBI=
github.com/unixshells/vt-go v0.1.0 h1:HWILcExV8MHc6o2YqIRMIM/KrjH31kPzZrlvLS9BG6Y=
github.com/unixshells/vt-go v0.1.0/go.mod h1:dZpOXxVyO7LG1uxkVMixX7HwUf2ZbUkEKhtHHJTTIBI=
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=
+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).
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
}