diff --git a/README.md b/README.md index 8689987..d89515f 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,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,6 +114,7 @@ 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 diff --git a/cmd/mosh-wasm/main.go b/cmd/mosh-wasm/main.go index 72ec297..9ec89b9 100644 --- a/cmd/mosh-wasm/main.go +++ b/cmd/mosh-wasm/main.go @@ -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{} { diff --git a/cmd/mosh-wasm/state.go b/cmd/mosh-wasm/state.go index e52ee38..dc907b3 100644 --- a/cmd/mosh-wasm/state.go +++ b/cmd/mosh-wasm/state.go @@ -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(), } } @@ -102,14 +107,24 @@ func (st *stateTracker) applyDiff(diff []byte, oldNum, newNum, throwawayNum uint } } - // Display diff: compute ANSI difference between displayed and latest. + // 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 } } @@ -125,6 +140,54 @@ 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() @@ -136,6 +199,7 @@ func (st *stateTracker) resize(cols, rows int) { st.shadowState = 0 st.states = make(map[uint64]*mosh.Framebuffer) st.displayedFB = nil + st.predictor.Reset() } // resetShadow clears the shadow emulator without reallocating it. diff --git a/predict.go b/predict.go new file mode 100644 index 0000000..3740ab1 --- /dev/null +++ b/predict.go @@ -0,0 +1,206 @@ +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 { + // Server hasn't caught up yet — stop checking. + 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 + } +} diff --git a/predict_test.go b/predict_test.go new file mode 100644 index 0000000..410ae2c --- /dev/null +++ b/predict_test.go @@ -0,0 +1,331 @@ +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 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 +}