Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f15d99fbc | ||
|
|
b8b5994527 |
@@ -20,6 +20,14 @@ type Client struct {
|
|||||||
output []byte // accumulated terminal output
|
output []byte // accumulated terminal output
|
||||||
outputC chan struct{}
|
outputC chan struct{}
|
||||||
|
|
||||||
|
// Action tracking for cumulative diffs.
|
||||||
|
actionsMu sync.Mutex
|
||||||
|
actions []UserInstruction
|
||||||
|
ackedActionCount int // how many actions the server has
|
||||||
|
lastAcked uint64
|
||||||
|
sentActionCounts map[uint64]int // sentNum → action count at that state
|
||||||
|
dirty bool // true = new actions since last tick
|
||||||
|
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
@@ -50,18 +58,20 @@ func Dial(host string, port int, key string) (*Client, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c := &Client{
|
c := &Client{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
transport: NewTransport(ocb, false),
|
transport: NewTransport(ocb, false),
|
||||||
ocb: ocb,
|
ocb: ocb,
|
||||||
outputC: make(chan struct{}, 1),
|
outputC: make(chan struct{}, 1),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
sentActionCounts: make(map[uint64]int),
|
||||||
}
|
}
|
||||||
|
|
||||||
c.wg.Add(2)
|
c.wg.Add(2)
|
||||||
go c.recvLoop()
|
go c.recvLoop()
|
||||||
go c.sendLoop()
|
go c.sendLoop()
|
||||||
|
|
||||||
// Send initial empty datagram to associate with the server.
|
// Send initial keepalive to associate with the server.
|
||||||
|
c.transport.ForceNextSend()
|
||||||
c.tick()
|
c.tick()
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
@@ -69,21 +79,21 @@ func Dial(host string, port int, key string) (*Client, error) {
|
|||||||
|
|
||||||
// Send sends keystrokes to the server.
|
// Send sends keystrokes to the server.
|
||||||
func (c *Client) Send(keys []byte) {
|
func (c *Client) Send(keys []byte) {
|
||||||
diff := MarshalUserMessage([]UserInstruction{{Keys: keys}})
|
c.actionsMu.Lock()
|
||||||
c.transport.SetPending(diff)
|
c.actions = append(c.actions, UserInstruction{Keys: append([]byte{}, keys...)})
|
||||||
c.tick()
|
c.dirty = true
|
||||||
|
c.actionsMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resize sends a resize to the server.
|
// Resize sends a resize to the server.
|
||||||
func (c *Client) Resize(cols, rows uint16) {
|
func (c *Client) Resize(cols, rows uint16) {
|
||||||
diff := MarshalUserMessage([]UserInstruction{{
|
c.actionsMu.Lock()
|
||||||
Width: int32(cols),
|
c.actions = append(c.actions, UserInstruction{Width: int32(cols), Height: int32(rows)})
|
||||||
Height: int32(rows),
|
c.dirty = true
|
||||||
}})
|
c.actionsMu.Unlock()
|
||||||
c.transport.SetPending(diff)
|
|
||||||
c.tick()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Recv reads accumulated terminal output, blocking until output is available
|
// Recv reads accumulated terminal output, blocking until output is available
|
||||||
// or the timeout expires. Returns nil on timeout.
|
// or the timeout expires. Returns nil on timeout.
|
||||||
func (c *Client) Recv(timeout time.Duration) []byte {
|
func (c *Client) Recv(timeout time.Duration) []byte {
|
||||||
@@ -132,11 +142,36 @@ func (c *Client) Transport() *Transport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) tick() {
|
func (c *Client) tick() {
|
||||||
|
// Batch: compute diff from accumulated actions.
|
||||||
|
c.actionsMu.Lock()
|
||||||
|
if c.dirty {
|
||||||
|
c.dirty = false
|
||||||
|
c.processAcksLocked()
|
||||||
|
newActions := c.actions[c.ackedActionCount:]
|
||||||
|
c.transport.SetPending(MarshalUserMessage(newActions))
|
||||||
|
c.sentActionCounts[c.transport.SentNum()+1] = len(c.actions)
|
||||||
|
}
|
||||||
|
c.actionsMu.Unlock()
|
||||||
|
|
||||||
for _, dg := range c.transport.Tick() {
|
for _, dg := range c.transport.Tick() {
|
||||||
c.conn.Write(dg)
|
c.conn.Write(dg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) processAcksLocked() {
|
||||||
|
acked := c.transport.AckedByRemote()
|
||||||
|
if acked > c.lastAcked {
|
||||||
|
c.lastAcked = acked
|
||||||
|
// Only advance base when server caught up (no states in flight).
|
||||||
|
if acked >= c.transport.SentNum() {
|
||||||
|
if count, ok := c.sentActionCounts[acked]; ok && count > c.ackedActionCount {
|
||||||
|
c.ackedActionCount = count
|
||||||
|
}
|
||||||
|
c.sentActionCounts = make(map[uint64]int)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) recvLoop() {
|
func (c *Client) recvLoop() {
|
||||||
defer c.wg.Done()
|
defer c.wg.Done()
|
||||||
buf := make([]byte, maxPayload+64)
|
buf := make([]byte, maxPayload+64)
|
||||||
|
|||||||
+76
-5
@@ -3,6 +3,7 @@ package mosh
|
|||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -10,6 +11,12 @@ import (
|
|||||||
"time"
|
"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.
|
// TestClientServerE2E tests the Go client against the Go server.
|
||||||
func TestClientServerE2E(t *testing.T) {
|
func TestClientServerE2E(t *testing.T) {
|
||||||
srv, err := NewServer("/bin/sh", 0, 0)
|
srv, err := NewServer("/bin/sh", 0, 0)
|
||||||
@@ -48,13 +55,13 @@ func TestClientServerE2E(t *testing.T) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-deadline:
|
case <-deadline:
|
||||||
t.Fatalf("marker not echoed. Got: %q", allOutput)
|
t.Fatalf("marker not echoed. Got: %q", clientStripANSI(allOutput))
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
out := client.Recv(500 * time.Millisecond)
|
out := client.Recv(500 * time.Millisecond)
|
||||||
if out != nil {
|
if out != nil {
|
||||||
allOutput += string(out)
|
allOutput += string(out)
|
||||||
if strings.Contains(allOutput, marker) {
|
if strings.Contains(clientStripANSI(allOutput), marker) {
|
||||||
t.Log("Go client → Go server E2E passed")
|
t.Log("Go client → Go server E2E passed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -103,7 +110,8 @@ func TestClientResize(t *testing.T) {
|
|||||||
out := client.Recv(500 * time.Millisecond)
|
out := client.Recv(500 * time.Millisecond)
|
||||||
if out != nil {
|
if out != nil {
|
||||||
allOutput += string(out)
|
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")
|
t.Log("Go client resize verified: 132x43")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -165,7 +173,7 @@ func TestClientServeRW(t *testing.T) {
|
|||||||
out := client.Recv(500 * time.Millisecond)
|
out := client.Recv(500 * time.Millisecond)
|
||||||
if out != nil {
|
if out != nil {
|
||||||
allOutput += string(out)
|
allOutput += string(out)
|
||||||
if strings.Contains(allOutput, "hello from latch") {
|
if strings.Contains(clientStripANSI(allOutput), "hello from latch") {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,6 +202,14 @@ func TestClientServeRW(t *testing.T) {
|
|||||||
t.Fatal("timed out waiting for keystrokes on pipe")
|
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.
|
// Resize.
|
||||||
client.Resize(200, 50)
|
client.Resize(200, 50)
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
@@ -284,7 +300,7 @@ func TestClientAgainstRealMoshServer(t *testing.T) {
|
|||||||
recv := client.Recv(500 * time.Millisecond)
|
recv := client.Recv(500 * time.Millisecond)
|
||||||
if recv != nil {
|
if recv != nil {
|
||||||
allOutput += string(recv)
|
allOutput += string(recv)
|
||||||
if strings.Contains(allOutput, marker) {
|
if strings.Contains(clientStripANSI(allOutput), marker) {
|
||||||
t.Log("Go client → real mosh-server E2E passed")
|
t.Log("Go client → real mosh-server E2E passed")
|
||||||
return
|
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.
|
// parseMoshConnect extracts port and key from mosh-server output.
|
||||||
func parseMoshConnect(output string) (int, string) {
|
func parseMoshConnect(output string) (int, string) {
|
||||||
for _, line := range strings.Split(output, "\n") {
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
package mosh
|
package mosh
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"image/color"
|
||||||
"strconv"
|
"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.
|
// Framebuffer represents a terminal screen state for mosh SSP.
|
||||||
@@ -361,3 +367,77 @@ func encodeRune(buf []byte, r rune) int {
|
|||||||
return 4
|
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)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,28 @@ module github.com/unixshells/mosh-go
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
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
|
github.com/creack/pty v1.1.24
|
||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/term v0.41.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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
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 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/x/vt"
|
||||||
"github.com/creack/pty"
|
"github.com/creack/pty"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,8 +29,8 @@ const (
|
|||||||
// Maximum payload per UDP datagram.
|
// Maximum payload per UDP datagram.
|
||||||
maxPayload = 16384
|
maxPayload = 16384
|
||||||
|
|
||||||
// Heartbeat / tick interval for the SSP send loop.
|
// Tick interval matches mosh's SEND_MINDELAY (8ms) for keystroke batching.
|
||||||
tickInterval = 20 * time.Millisecond
|
tickInterval = 8 * time.Millisecond
|
||||||
|
|
||||||
// Server gives up waiting for client after this.
|
// Server gives up waiting for client after this.
|
||||||
associationTimeout = 60 * time.Second
|
associationTimeout = 60 * time.Second
|
||||||
@@ -59,6 +61,12 @@ type Server struct {
|
|||||||
// Transport handles SSP sequencing, fragmentation, and crypto.
|
// Transport handles SSP sequencing, fragmentation, and crypto.
|
||||||
transport *Transport
|
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.
|
// Remote client address — set on first received datagram.
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
clientAddr *net.UDPAddr
|
clientAddr *net.UDPAddr
|
||||||
@@ -154,6 +162,13 @@ func (s *Server) Serve() error {
|
|||||||
Cols: uint16(s.cols),
|
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
|
var wg sync.WaitGroup
|
||||||
wg.Add(3)
|
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 {
|
func (s *Server) ServeRW(rw io.ReadWriteCloser, resize func(cols, rows uint16)) error {
|
||||||
close(s.started)
|
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
|
var wg sync.WaitGroup
|
||||||
wg.Add(3)
|
wg.Add(3)
|
||||||
|
|
||||||
@@ -265,7 +287,7 @@ func (s *Server) mainLoopRW(rw io.Writer, resize func(cols, rows uint16), ioOutp
|
|||||||
ticker := time.NewTicker(tickInterval)
|
ticker := time.NewTicker(tickInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
var pending []byte
|
dirty := false
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -276,7 +298,8 @@ func (s *Server) mainLoopRW(rw io.Writer, resize func(cols, rows uint16), ioOutp
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pending = append(pending, data...)
|
s.emu.Write(data)
|
||||||
|
dirty = true
|
||||||
|
|
||||||
case ui := <-userInput:
|
case ui := <-userInput:
|
||||||
if len(ui.Keys) > 0 {
|
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 {
|
if resize != nil {
|
||||||
resize(uint16(ui.Width), uint16(ui.Height))
|
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:
|
case <-ticker.C:
|
||||||
if len(pending) > 0 {
|
if s.sentFB != nil && s.transport.AckedByRemote() >= s.transport.SentNum() {
|
||||||
hi := HostInstruction{
|
s.baseFB = s.sentFB
|
||||||
Hoststring: pending,
|
s.sentFB = nil
|
||||||
EchoAckNum: -1,
|
|
||||||
}
|
|
||||||
diff := marshalHostMessage([]HostInstruction{hi})
|
|
||||||
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()
|
datagrams := s.transport.Tick()
|
||||||
s.sendDatagrams(datagrams)
|
s.sendDatagrams(datagrams)
|
||||||
}
|
}
|
||||||
@@ -331,8 +365,9 @@ func (s *Server) Close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mainLoop is the SSP event loop. It maintains the terminal framebuffer,
|
// mainLoop is the SSP event loop. It feeds PTY output to a VT emulator,
|
||||||
// processes user input, diffs the screen, and drives the transport.
|
// 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) {
|
func (s *Server) mainLoop(ptyOutput <-chan []byte, userInput <-chan UserInstruction) {
|
||||||
// Wait for client to connect before processing.
|
// Wait for client to connect before processing.
|
||||||
for {
|
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)
|
ticker := time.NewTicker(tickInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
var pending []byte
|
dirty := false
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -367,14 +398,13 @@ func (s *Server) mainLoop(ptyOutput <-chan []byte, userInput <-chan UserInstruct
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pending = append(pending, data...)
|
s.emu.Write(data)
|
||||||
|
dirty = true
|
||||||
|
|
||||||
case ui := <-userInput:
|
case ui := <-userInput:
|
||||||
// Handle keystrokes.
|
|
||||||
if len(ui.Keys) > 0 {
|
if len(ui.Keys) > 0 {
|
||||||
s.ptmx.Write(ui.Keys)
|
s.ptmx.Write(ui.Keys)
|
||||||
}
|
}
|
||||||
// Handle resize.
|
|
||||||
if ui.Width > 0 && ui.Height > 0 {
|
if ui.Width > 0 && ui.Height > 0 {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.cols = int(ui.Width)
|
s.cols = int(ui.Width)
|
||||||
@@ -384,20 +414,28 @@ func (s *Server) mainLoop(ptyOutput <-chan []byte, userInput <-chan UserInstruct
|
|||||||
Cols: uint16(ui.Width),
|
Cols: uint16(ui.Width),
|
||||||
Rows: uint16(ui.Height),
|
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:
|
case <-ticker.C:
|
||||||
// Build diff and send via transport.
|
// Advance base when client acks all pending.
|
||||||
if len(pending) > 0 {
|
if s.sentFB != nil && s.transport.AckedByRemote() >= s.transport.SentNum() {
|
||||||
hi := HostInstruction{
|
s.baseFB = s.sentFB
|
||||||
Hoststring: pending,
|
s.sentFB = nil
|
||||||
EchoAckNum: -1,
|
}
|
||||||
}
|
|
||||||
diff := marshalHostMessage([]HostInstruction{hi})
|
|
||||||
|
|
||||||
// Wrap in TransportInstruction via transport.
|
if dirty {
|
||||||
s.transport.SetPending(diff)
|
currentFB := SnapshotEmulator(s.emu, s.curVisible.Load())
|
||||||
pending = nil
|
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()
|
datagrams := s.transport.Tick()
|
||||||
|
|||||||
+14
-6
@@ -8,6 +8,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -16,6 +17,13 @@ import (
|
|||||||
"github.com/creack/pty"
|
"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) {
|
func TestGenerateKey(t *testing.T) {
|
||||||
key, b64, err := GenerateKey()
|
key, b64, err := GenerateKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -175,13 +183,13 @@ func TestServerE2E(t *testing.T) {
|
|||||||
for !found {
|
for !found {
|
||||||
select {
|
select {
|
||||||
case <-deadline:
|
case <-deadline:
|
||||||
t.Fatalf("marker not echoed. Got: %q", allOutput)
|
t.Fatalf("marker not echoed. Got: %q", stripANSI(allOutput))
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
payload := recvClient(500 * time.Millisecond)
|
payload := recvClient(500 * time.Millisecond)
|
||||||
if payload != nil {
|
if payload != nil {
|
||||||
allOutput += string(payload)
|
allOutput += string(payload)
|
||||||
if strings.Contains(allOutput, marker) {
|
if strings.Contains(stripANSI(allOutput), marker) {
|
||||||
found = true
|
found = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,14 +512,14 @@ func TestServeRW(t *testing.T) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-deadline:
|
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:
|
default:
|
||||||
}
|
}
|
||||||
payload := recvClient(500 * time.Millisecond)
|
payload := recvClient(500 * time.Millisecond)
|
||||||
if payload != nil {
|
if payload != nil {
|
||||||
allOutput += string(payload)
|
allOutput += string(payload)
|
||||||
}
|
}
|
||||||
if strings.Contains(allOutput, "HELLO_RW") {
|
if strings.Contains(stripANSI(allOutput), "HELLO_RW") {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
sendClient(nil)
|
sendClient(nil)
|
||||||
@@ -625,14 +633,14 @@ func TestServeRW(t *testing.T) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-deadline2:
|
case <-deadline2:
|
||||||
t.Fatalf("did not receive terminal output, got: %q", allOutput2)
|
t.Fatalf("did not receive terminal output, got: %q", stripANSI(allOutput2))
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
payload := recvClient2(500 * time.Millisecond)
|
payload := recvClient2(500 * time.Millisecond)
|
||||||
if payload != nil {
|
if payload != nil {
|
||||||
allOutput2 += string(payload)
|
allOutput2 += string(payload)
|
||||||
}
|
}
|
||||||
if strings.Contains(allOutput2, "OUTPUT_OK") {
|
if strings.Contains(stripANSI(allOutput2), "OUTPUT_OK") {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
sendClient2(nil)
|
sendClient2(nil)
|
||||||
|
|||||||
+119
-26
@@ -26,13 +26,18 @@ type Transport struct {
|
|||||||
|
|
||||||
// Outgoing state (SSP §3).
|
// Outgoing state (SSP §3).
|
||||||
sentNum uint64 // newest state we've sent (new_num)
|
sentNum uint64 // newest state we've sent (new_num)
|
||||||
ackedNum uint64 // newest state the remote has acknowledged (ack from remote)
|
ackedByRemote uint64 // newest state the remote has acknowledged
|
||||||
pendingDiff []byte // diff payload waiting to be sent
|
pendingDiff []byte // diff payload waiting to be sent
|
||||||
|
diffSent bool // true = pendingDiff has been sent at least once
|
||||||
|
diffOldNum uint64 // locked oldNum for all diffs until base advances
|
||||||
|
hasPendingBase bool // true = diffOldNum is locked
|
||||||
|
pendingDataAck bool // true = send ack ASAP (received data, not just ack)
|
||||||
|
|
||||||
// Incoming state.
|
// Incoming state — list of received state nums for old_num validation.
|
||||||
recvNum uint64 // newest state we've received from remote (their new_num)
|
receivedNums []uint64 // ordered list of state nums we have
|
||||||
recvAck uint64 // what we've told the remote we received (our ack_num)
|
ackNum uint64 // latest received state num
|
||||||
throwawayNum uint64 // oldest state we still hold
|
sentAckNum uint64 // last ackNum we actually sent on wire
|
||||||
|
throwawayNum uint64 // oldest state we still hold
|
||||||
|
|
||||||
// Sequence counter for the crypto layer (independent of SSP state numbering).
|
// Sequence counter for the crypto layer (independent of SSP state numbering).
|
||||||
seqOut uint64
|
seqOut uint64
|
||||||
@@ -67,10 +72,11 @@ const (
|
|||||||
// NewTransport creates a transport. isServer determines direction bits.
|
// NewTransport creates a transport. isServer determines direction bits.
|
||||||
func NewTransport(ocb *OCB, isServer bool) *Transport {
|
func NewTransport(ocb *OCB, isServer bool) *Transport {
|
||||||
t := &Transport{
|
t := &Transport{
|
||||||
ocb: ocb,
|
ocb: ocb,
|
||||||
rto: initialRTO,
|
rto: initialRTO,
|
||||||
lastSend: time.Now(),
|
lastSend: time.Now(),
|
||||||
lastRecv: time.Now(),
|
lastRecv: time.Now(),
|
||||||
|
receivedNums: []uint64{0}, // start with state 0
|
||||||
}
|
}
|
||||||
if isServer {
|
if isServer {
|
||||||
t.toRemote = dirToClient
|
t.toRemote = dirToClient
|
||||||
@@ -107,9 +113,19 @@ func (t *Transport) HasCap(bit byte) bool {
|
|||||||
return (t.localCaps[idx] & t.remoteCaps[idx] & bit) != 0
|
return (t.localCaps[idx] & t.remoteCaps[idx] & bit) != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForceNextSend forces the next tick to send, even with no pending diff.
|
||||||
|
func (t *Transport) ForceNextSend() {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.lastSend = time.Time{} // zero time, always expired
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// SetPending sets the diff payload to send on the next tick.
|
// SetPending sets the diff payload to send on the next tick.
|
||||||
func (t *Transport) SetPending(diff []byte) {
|
func (t *Transport) SetPending(diff []byte) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
|
if len(diff) > 0 {
|
||||||
|
t.diffSent = false
|
||||||
|
}
|
||||||
t.pendingDiff = diff
|
t.pendingDiff = diff
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -124,29 +140,44 @@ func (t *Transport) Tick() [][]byte {
|
|||||||
|
|
||||||
// Decide if we should send.
|
// Decide if we should send.
|
||||||
haveDiff := len(t.pendingDiff) > 0
|
haveDiff := len(t.pendingDiff) > 0
|
||||||
needAck := t.recvNum > t.recvAck
|
haveNewDiff := haveDiff && !t.diffSent
|
||||||
expired := now.Sub(t.lastSend) >= t.rto
|
needAck := t.ackNum > t.sentAckNum
|
||||||
|
sinceLastSend := now.Sub(t.lastSend)
|
||||||
|
expired := sinceLastSend >= t.rto
|
||||||
|
urgentAck := t.pendingDataAck
|
||||||
|
|
||||||
if !haveDiff && !needAck && !expired {
|
shouldSend := haveNewDiff || needAck || expired || urgentAck
|
||||||
|
if !shouldSend {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build TransportInstruction.
|
// Build TransportInstruction.
|
||||||
if haveDiff {
|
if haveNewDiff {
|
||||||
t.sentNum++
|
t.sentNum++
|
||||||
|
t.diffSent = true
|
||||||
|
if !t.hasPendingBase {
|
||||||
|
t.diffOldNum = t.ackedByRemote
|
||||||
|
t.hasPendingBase = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.pendingDataAck = false
|
||||||
|
|
||||||
|
oldNum := t.ackedByRemote
|
||||||
|
if haveDiff {
|
||||||
|
oldNum = t.diffOldNum // use locked oldNum for diff retransmissions
|
||||||
}
|
}
|
||||||
|
|
||||||
ti := TransportInstruction{
|
ti := TransportInstruction{
|
||||||
ProtocolVersion: 2, // mosh protocol version
|
ProtocolVersion: 2,
|
||||||
OldNum: t.ackedNum,
|
OldNum: oldNum,
|
||||||
NewNum: t.sentNum,
|
NewNum: t.sentNum,
|
||||||
AckNum: t.recvNum,
|
AckNum: t.ackNum,
|
||||||
ThrowawayNum: t.throwawayNum,
|
ThrowawayNum: 0, // client doesn't tell server to throw away states
|
||||||
Diff: t.pendingDiff,
|
Diff: t.pendingDiff,
|
||||||
LatchCaps: t.localCaps,
|
LatchCaps: t.localCaps,
|
||||||
}
|
}
|
||||||
t.recvAck = t.recvNum
|
t.sentAckNum = t.ackNum
|
||||||
t.pendingDiff = nil
|
// Do NOT nil pendingDiff — keep for retransmission until server acks.
|
||||||
|
|
||||||
// Marshal → compress → fragment → encrypt.
|
// Marshal → compress → fragment → encrypt.
|
||||||
pbData := ti.Marshal()
|
pbData := ti.Marshal()
|
||||||
@@ -250,22 +281,84 @@ func (t *Transport) Recv(wire []byte) []byte {
|
|||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process SSP fields.
|
// Process SSP fields (matching upstream mosh recv logic).
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
if ti.AckNum > t.ackedNum {
|
defer t.mu.Unlock()
|
||||||
t.ackedNum = ti.AckNum
|
|
||||||
|
// Process ack from remote.
|
||||||
|
if ti.AckNum > t.ackedByRemote {
|
||||||
|
t.ackedByRemote = ti.AckNum
|
||||||
|
if t.ackedByRemote >= t.sentNum && t.pendingDiff != nil {
|
||||||
|
t.pendingDiff = nil
|
||||||
|
t.diffSent = false
|
||||||
|
t.hasPendingBase = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ti.NewNum > t.recvNum {
|
|
||||||
t.recvNum = ti.NewNum
|
// Check if we already have new_num (dedup).
|
||||||
|
for _, n := range t.receivedNums {
|
||||||
|
if n == ti.NewNum {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process throwaway.
|
||||||
if ti.ThrowawayNum > t.throwawayNum {
|
if ti.ThrowawayNum > t.throwawayNum {
|
||||||
t.throwawayNum = ti.ThrowawayNum
|
t.throwawayNum = ti.ThrowawayNum
|
||||||
|
filtered := t.receivedNums[:0]
|
||||||
|
for _, n := range t.receivedNums {
|
||||||
|
if n >= t.throwawayNum {
|
||||||
|
filtered = append(filtered, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.receivedNums = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new state.
|
||||||
|
t.receivedNums = append(t.receivedNums, ti.NewNum)
|
||||||
|
|
||||||
|
// Bound the list.
|
||||||
|
if len(t.receivedNums) > 128 {
|
||||||
|
t.receivedNums = t.receivedNums[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ack num to latest received state.
|
||||||
|
t.ackNum = ti.NewNum
|
||||||
|
|
||||||
|
// Trigger immediate ack when we receive data.
|
||||||
|
if len(ti.Diff) > 0 {
|
||||||
|
t.pendingDataAck = true
|
||||||
}
|
}
|
||||||
t.mu.Unlock()
|
|
||||||
|
|
||||||
return ti.Diff
|
return ti.Diff
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AckedByRemote returns the highest state number the remote has acked.
|
||||||
|
func (t *Transport) AckedByRemote() uint64 {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.ackedByRemote
|
||||||
|
}
|
||||||
|
|
||||||
|
// SentNum returns the current sent state number.
|
||||||
|
func (t *Transport) SentNum() uint64 {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.sentNum
|
||||||
|
}
|
||||||
|
|
||||||
// LastRecv returns the time of the last received datagram.
|
// LastRecv returns the time of the last received datagram.
|
||||||
func (t *Transport) LastRecv() time.Time {
|
func (t *Transport) LastRecv() time.Time {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
|
|||||||
Reference in New Issue
Block a user