23 Commits
Author SHA1 Message Date
realrasengan 0284bd652a revert vt-go to v0.1.0 (v0.2.0 scrollback changes break mosh) 2026-03-31 19:19:02 -07:00
realrasengan c82251169f revert: use NewEmulator with scrollback (NoScrollback breaks mosh rendering) 2026-03-31 19:04:53 -07:00
realrasengan f065464e2c mosh server: disable scrollback in VT emulator (saves ~200MB/session) 2026-03-31 17:55:00 -07:00
realrasengan 534e7da40d fix state tracker desync: prune by throwawayNum instead of arbitrary limit 2026-03-31 17:34:29 -07:00
realrasengan 96ce8cef8d fix attr bitmasks: match ultraviolet (RapidBlink/Conceal shifted Reverse/Strike) 2026-03-30 21:30:50 -07:00
realrasengan 052945f9f8 fix fullRedraw skipping attributed spaces (nano title bar) 2026-03-30 21:28:20 -07:00
realrasengan aff8e0572a fix resize: accept first diff after reset without base state 2026-03-30 21:24:44 -07:00
realrasengan f70f18fc3f add DialConnRaw + RecvRaw for direct transport access in WASM 2026-03-30 20:55:37 -07:00
realrasengan 8d9bc2eba0 add framebuffer state tracking to WASM client (match Dart)
- Transport: expose LastRecvOldNum/LastRecvNewNum for state tracking
- WASM: stateTracker with shadow VT emulator and received states map
- Each diff applied to base state copy, stored by newNum
- Display diffing: only changed cells sent to terminal
- Eliminates garbled output from missed diffs and state transitions
- Build with -tags osusergo for WASM os/user compatibility
2026-03-30 20:47:18 -07:00
realrasengan 4a40090a5f switch to unixshells/vt-go, ungate framebuffer for WASM
- Replace charmbracelet/x/vt with unixshells/vt-go (IRM support)
- Remove ultraviolet import from framebuffer.go (use local attr constants)
- Framebuffer compiles for all platforms including js/wasm
- WASM build requires -tags osusergo for os/user compatibility
2026-03-30 20:35:55 -07:00
realrasengan b71a9c5824 fix key repeat: only create new state when previous is acked (match Dart client)
The Dart mosh client only calls sendNew() when hasPendingState is false.
The Go client was calling SetPending on every tick with dirty=true, which
reset diffSent and caused new sequence numbers for the same cumulative
payload. Now tick() checks AckedByRemote >= SentNum before calling
SetPending — at most one unacked state in flight, matching the Dart
client's behavior.
2026-03-30 19:56:45 -07:00
realrasengan 2939a82eca add 60s association timeout: kill mosh server if no client connects
Prevents stale mosh servers from failed setups holding relay bridges
indefinitely. Once a client connects, the 24h network timeout applies.
2026-03-30 19:46:22 -07:00
realrasengan c582e52c15 WASM: replace goroutine recv with JS-driven poll() to prevent scheduler starvation 2026-03-30 18:53:48 -07:00
realrasengan ad1792735e WASM: tick immediately when idle, batch when ack pending 2026-03-30 18:45:45 -07:00
realrasengan 1694f8c993 WASM: remove tick from send, let interval handle all flushing 2026-03-30 18:44:31 -07:00
realrasengan ecc46b8df2 WASM: batch keystrokes per animation frame to prevent cumulative state duplication 2026-03-30 17:25:05 -07:00
realrasengan 83b9ce0de5 WASM: defer next datagram read via setTimeout to yield to Go scheduler 2026-03-30 16:42:33 -07:00
realrasengan 37159d31bd WASM: use persistent writer, fix connection lost from lock churn 2026-03-30 16:19:17 -07:00
realrasengan 30eb6a82a5 WASM: tick immediately on send/resize, slow keepalive interval only 2026-03-30 16:07:58 -07:00
realrasengan cc1f22485d remove immediate Tick from send/resize — let setInterval drive it 2026-03-30 15:43:33 -07:00
realrasengan d8d48e4269 fix WASM: self-chaining JS reader, no goroutine leak, per-write lock 2026-03-30 14:35:40 -07:00
realrasengan 5f8cade139 add Tick() export and DialConnManual for JS-driven send loop in WASM 2026-03-30 14:24:44 -07:00
realrasengan bbe416444b add Conn interface, WASM client with WebTransport transport
- Conn interface: abstracts datagram I/O (Read/Write/SetReadDeadline/Close)
- Client.DialConn(): create client over any Conn (not just UDP)
- cmd/mosh-wasm: WASM binary exposing moshConnect(url, key) to JS
- WebTransport adapter: browser datagrams as mosh.Conn
- Build tags: server.go and framebuffer.go excluded from js/wasm
- Shared constants moved to conn.go

Existing UDP paths unchanged. Latch unaffected.
2026-03-30 12:05:33 -07:00
13 changed files with 799 additions and 40 deletions
+89 -5
View File
@@ -12,7 +12,7 @@ import (
// Client is a mosh client. It connects to a mosh server over UDP,
// handles the SSP transport, and provides send/recv for terminal I/O.
type Client struct {
conn *net.UDPConn
conn Conn
transport *Transport
ocb *OCB
@@ -32,8 +32,8 @@ type Client struct {
wg sync.WaitGroup
}
// Dial connects to a mosh server. The key is the base64-encoded mosh key
// (with or without padding).
// Dial connects to a mosh server over UDP. The key is the base64-encoded
// mosh key (with or without padding).
func Dial(host string, port int, key string) (*Client, error) {
// Pad key for base64 if needed.
for len(key)%4 != 0 {
@@ -57,6 +57,12 @@ func Dial(host string, port int, key string) (*Client, error) {
return nil, err
}
return DialConn(conn, ocb)
}
// DialConn creates a mosh client over an existing datagram connection.
// Use this with WebTransport or other non-UDP transports.
func DialConn(conn Conn, ocb *OCB) (*Client, error) {
c := &Client{
conn: conn,
transport: NewTransport(ocb, false),
@@ -77,6 +83,71 @@ func Dial(host string, port int, key string) (*Client, error) {
return c, nil
}
// DialConnManual creates a mosh client without an internal sendLoop.
// The caller must call Tick() periodically (e.g., every 8-16ms) to flush
// outgoing datagrams. Use this in WASM where Go timers are unreliable.
func DialConnManual(conn Conn, ocb *OCB) (*Client, error) {
c := &Client{
conn: conn,
transport: NewTransport(ocb, false),
ocb: ocb,
outputC: make(chan struct{}, 1),
done: make(chan struct{}),
sentActionCounts: make(map[uint64]int),
}
c.wg.Add(1)
go c.recvLoop()
// Send initial keepalive to associate with the server.
c.transport.ForceNextSend()
c.tick()
return c, nil
}
// DialConnRaw creates a mosh client with no internal goroutines.
// The caller must call Tick() for sending AND RecvRaw() for receiving.
// Use this when the caller needs raw transport diffs (e.g., for
// framebuffer state tracking in the WASM client).
func DialConnRaw(conn Conn, ocb *OCB) (*Client, error) {
c := &Client{
conn: conn,
transport: NewTransport(ocb, false),
ocb: ocb,
outputC: make(chan struct{}, 1),
done: make(chan struct{}),
sentActionCounts: make(map[uint64]int),
}
c.transport.ForceNextSend()
c.tick()
return c, nil
}
// RecvRaw reads one datagram from the connection and processes it
// through the transport. Returns the raw diff payload, or nil if
// no complete message was received (timeout, fragment, replay).
// The caller can use Transport().LastRecvOldNum()/LastRecvNewNum()
// to get the state numbers for this diff.
func (c *Client) RecvRaw(timeout time.Duration) []byte {
buf := make([]byte, maxPayload+64)
c.conn.SetReadDeadline(time.Now().Add(timeout))
n, err := c.conn.Read(buf)
if err != nil {
return nil
}
if n < minDatagram {
return nil
}
data := make([]byte, n)
copy(data, buf[:n])
return c.transport.Recv(data)
}
// Send sends keystrokes to the server.
func (c *Client) Send(keys []byte) {
c.actionsMu.Lock()
@@ -141,10 +212,23 @@ func (c *Client) Transport() *Transport {
return c.transport
}
// Tick flushes pending actions as mosh datagrams. Call this periodically
// from an external timer (e.g., JS setInterval in WASM) when the internal
// sendLoop is not used.
func (c *Client) Tick() {
c.tick()
}
func (c *Client) tick() {
// Batch: compute diff from accumulated actions.
// Only create a new state when the server has acked the previous one.
// This matches the Dart mosh client's behavior: at most one unacked
// state in flight. Keystrokes accumulate until the ack arrives, then
// the next tick sends them all as a single new state.
// Without this check, SetPending resets diffSent, causing Tick() to
// increment sentNum for the same cumulative payload — the server
// applies duplicate keystrokes.
c.actionsMu.Lock()
if c.dirty {
if c.dirty && c.transport.AckedByRemote() >= c.transport.SentNum() {
c.dirty = false
c.processAcksLocked()
newActions := c.actions[c.ackedActionCount:]
+168
View File
@@ -0,0 +1,168 @@
//go:build js && wasm
package main
import (
"encoding/base64"
"fmt"
"sync"
"syscall/js"
"time"
mosh "github.com/unixshells/mosh-go"
)
func main() {
js.Global().Set("moshConnect", js.FuncOf(moshConnect))
select {} // keep alive
}
// moshConnect(url, key, cols, rows) → Promise<MoshSession>
func moshConnect(this js.Value, args []js.Value) interface{} {
if len(args) < 4 {
return reject("moshConnect requires (url, key, cols, rows)")
}
url := args[0].String()
key := args[1].String()
cols := args[2].Int()
rows := args[3].Int()
handler := js.FuncOf(func(this js.Value, pargs []js.Value) interface{} {
resolve := pargs[0]
rejectFn := pargs[1]
go func() {
session, err := newSession(url, key, cols, rows)
if err != nil {
rejectFn.Invoke(err.Error())
return
}
resolve.Invoke(session.jsObject())
}()
return nil
})
return js.Global().Get("Promise").New(handler)
}
type session struct {
client *mosh.Client
conn *wtConn
tracker *stateTracker
mu sync.Mutex
closed bool
}
func newSession(url, key string, cols, rows int) (*session, error) {
for len(key)%4 != 0 {
key += "="
}
rawKey, err := base64.StdEncoding.DecodeString(key)
if err != nil {
return nil, fmt.Errorf("bad key: %w", err)
}
ocb, err := mosh.NewOCB(rawKey)
if err != nil {
return nil, err
}
conn, err := dialWebTransport(url)
if err != nil {
return nil, err
}
client, err := mosh.DialConnRaw(conn, ocb)
if err != nil {
conn.Close()
return nil, err
}
s := &session{
client: client,
conn: conn,
tracker: newStateTracker(cols, rows),
}
// Background goroutine: read from mosh client, feed to state tracker.
go s.recvLoop()
return s, nil
}
func (s *session) jsObject() js.Value {
obj := js.Global().Get("Object").New()
obj.Set("send", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) < 1 {
return nil
}
s.client.Send([]byte(args[0].String()))
s.client.Tick()
return nil
}))
obj.Set("resize", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) < 2 {
return nil
}
cols := args[0].Int()
rows := args[1].Int()
s.client.Resize(uint16(cols), uint16(rows))
s.tracker.resize(cols, rows)
s.client.Tick()
return nil
}))
// tick() — flush pending keystrokes + keepalive/retransmit.
obj.Set("tick", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
s.client.Tick()
return nil
}))
// poll() — returns pre-diffed ANSI output from the state tracker.
obj.Set("poll", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
out := s.tracker.poll()
if out == nil {
return js.Null()
}
return string(out)
}))
obj.Set("close", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
s.mu.Lock()
s.closed = true
s.mu.Unlock()
s.client.Close()
return nil
}))
return obj
}
// recvLoop reads raw transport diffs and feeds them to the state
// tracker for framebuffer-based processing.
func (s *session) recvLoop() {
for {
diff := s.client.RecvRaw(1 * time.Second)
if diff == nil {
s.mu.Lock()
closed := s.closed
s.mu.Unlock()
if closed {
return
}
continue
}
t := s.client.Transport()
oldNum := t.LastRecvOldNum()
newNum := t.LastRecvNewNum()
throwawayNum := t.ThrowawayNum()
s.tracker.applyDiff(diff, oldNum, newNum, throwawayNum)
}
}
func reject(msg string) js.Value {
return js.Global().Get("Promise").Call("reject", msg)
}
+140
View File
@@ -0,0 +1,140 @@
//go:build js && wasm
package main
import (
"sync"
mosh "github.com/unixshells/mosh-go"
vt "github.com/unixshells/vt-go"
)
// stateTracker implements Dart-style received state management.
// Each incoming diff is applied to a copy of the base state (looked up
// by oldNum), producing a new state stored by newNum. The display
// always shows the latest received state, diffed against what's
// currently on screen. This eliminates garbled output from missed
// diffs and overlapping state transitions.
type stateTracker struct {
mu sync.Mutex
shadow *vt.Emulator
shadowState uint64
cols, rows int
// Received states: stateNum → framebuffer snapshot.
states map[uint64]*mosh.Framebuffer
latestState uint64
displayedFB *mosh.Framebuffer
// 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),
}
}
// applyDiff processes an incoming server diff using state tracking.
// throwawayNum is the server's indication of which states it no longer references.
func (st *stateTracker) applyDiff(diff []byte, oldNum, newNum, throwawayNum uint64) {
st.mu.Lock()
defer st.mu.Unlock()
// Already have this state — skip.
if _, ok := st.states[newNum]; ok {
return
}
// Snapshot current shadow as oldNum if we don't have it.
if oldNum == st.shadowState {
if _, ok := st.states[oldNum]; !ok {
st.states[oldNum] = mosh.SnapshotEmulator(st.shadow, true)
}
}
// 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)
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.
if base != nil {
st.shadow.Write(base.Diff(nil))
}
}
// Apply diff: feed hoststrings to shadow.
instrs, err := mosh.UnmarshalHostMessage(diff)
if err != nil {
return
}
for _, hi := range instrs {
if len(hi.Hoststring) > 0 {
st.shadow.Write(hi.Hoststring)
}
}
st.shadowState = newNum
// Snapshot result.
snap := mosh.SnapshotEmulator(st.shadow, true)
st.states[newNum] = snap
if newNum > st.latestState {
st.latestState = newNum
}
// Prune states the server no longer references.
if throwawayNum > 0 {
for n := range st.states {
if n < throwawayNum {
delete(st.states, n)
}
}
}
// Display diff: compute ANSI difference between displayed and latest.
latest := st.states[st.latestState]
if latest != nil {
ansi := latest.Diff(st.displayedFB)
if len(ansi) > 0 {
st.output = append(st.output, ansi...)
}
st.displayedFB = latest
}
}
// poll returns accumulated ANSI output, or nil.
func (st *stateTracker) poll() []byte {
st.mu.Lock()
defer st.mu.Unlock()
if len(st.output) == 0 {
return nil
}
out := st.output
st.output = nil
return out
}
// 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
st.shadow = vt.NewEmulator(cols, rows)
st.shadowState = 0
st.states = make(map[uint64]*mosh.Framebuffer)
st.displayedFB = nil
}
+158
View File
@@ -0,0 +1,158 @@
//go:build js && wasm
package main
import (
"errors"
"sync"
"syscall/js"
"time"
)
// wtConn wraps a browser WebTransport connection as a mosh.Conn.
type wtConn struct {
transport js.Value
writer js.Value // persistent writable stream writer
incoming chan []byte
done chan struct{}
once sync.Once
mu sync.Mutex
deadline time.Time
}
func dialWebTransport(url string) (*wtConn, error) {
wt := js.Global().Get("WebTransport").New(url)
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 := "WebTransport connection 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 nil, err
}
case <-time.After(10 * time.Second):
onReady.Release()
onFail.Release()
wt.Call("close")
return nil, errors.New("WebTransport connect timeout")
}
onReady.Release()
onFail.Release()
writer := wt.Get("datagrams").Get("writable").Call("getWriter")
c := &wtConn{
transport: wt,
writer: writer,
incoming: make(chan []byte, 256),
done: make(chan struct{}),
}
// Start reading datagrams via a persistent JS callback.
c.startReader()
return c, nil
}
// startReader sets up a self-chaining read loop using persistent callbacks.
func (c *wtConn) startReader() {
reader := c.transport.Get("datagrams").Get("readable").Call("getReader")
var onData, onErr js.Func
var readNext js.Func
readNext = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
reader.Call("read").Call("then", onData, onErr)
return nil
})
onData = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
result := args[0]
if result.Get("done").Bool() {
return nil
}
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:
}
// Defer next read to next event loop tick so Go goroutines can process.
js.Global().Call("setTimeout", readNext, 0)
return nil
})
onErr = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
// Stream error or closed — stop reading.
return nil
})
// Start first read.
readNext.Invoke()
}
func (c *wtConn) Read(b []byte) (int, error) {
c.mu.Lock()
deadline := c.deadline
c.mu.Unlock()
var timer <-chan time.Time
if !deadline.IsZero() {
d := time.Until(deadline)
if d <= 0 {
return 0, errors.New("i/o timeout")
}
timer = time.After(d)
}
select {
case <-c.done:
return 0, errors.New("connection closed")
case data := <-c.incoming:
n := copy(b, data)
return n, nil
case <-timer:
return 0, errors.New("i/o timeout")
}
}
func (c *wtConn) Write(b []byte) (int, error) {
arr := js.Global().Get("Uint8Array").New(len(b))
js.CopyBytesToJS(arr, b)
c.writer.Call("write", arr)
return len(b), nil
}
func (c *wtConn) SetReadDeadline(t time.Time) error {
c.mu.Lock()
c.deadline = t
c.mu.Unlock()
return nil
}
func (c *wtConn) Close() error {
c.once.Do(func() {
close(c.done)
c.transport.Call("close")
})
return nil
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"encoding/base64"
"fmt"
"net"
"os"
"strconv"
"time"
"unicode/utf8"
mosh "github.com/unixshells/mosh-go"
)
func main() {
if len(os.Args) != 4 {
fmt.Fprintf(os.Stderr, "Usage: %s <host> <port> <base64key>\n", os.Args[0])
os.Exit(1)
}
host := os.Args[1]
port, _ := strconv.Atoi(os.Args[2])
key, err := base64.StdEncoding.DecodeString(os.Args[3])
if err != nil {
// Try raw base64 without padding.
key, err = base64.RawStdEncoding.DecodeString(os.Args[2+1])
if err != nil {
fmt.Fprintf(os.Stderr, "bad key: %v\n", err)
os.Exit(1)
}
}
ocb, err := mosh.NewOCB(key)
if err != nil {
fmt.Fprintf(os.Stderr, "ocb: %v\n", err)
os.Exit(1)
}
transport := mosh.NewTransport(ocb, false) // client mode
// Resolve host.
addrs, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, port))
if err != nil {
fmt.Fprintf(os.Stderr, "resolve: %v\n", err)
os.Exit(1)
}
conn, err := net.DialUDP("udp", nil, addrs)
if err != nil {
fmt.Fprintf(os.Stderr, "dial: %v\n", err)
os.Exit(1)
}
defer conn.Close()
flush := func() {
datagrams := transport.Tick()
for _, dg := range datagrams {
conn.Write(dg)
}
}
// Read incoming in background.
go func() {
buf := make([]byte, 65536)
for {
n, err := conn.Read(buf)
if err != nil {
return
}
diff := transport.Recv(buf[:n])
if diff == nil || len(diff) == 0 {
continue
}
instrs, err := mosh.UnmarshalHostMessage(diff)
if err != nil {
fmt.Printf("unmarshal error: %v\n", err)
continue
}
for _, hi := range instrs {
if len(hi.Hoststring) > 0 {
text := string(hi.Hoststring)
if !utf8.ValidString(text) {
text = fmt.Sprintf("(%d raw bytes)", len(hi.Hoststring))
}
fmt.Printf("=== hoststring (%d bytes) ===\n%s\n=== end ===\n", len(hi.Hoststring), text)
}
if hi.Width > 0 || hi.Height > 0 {
fmt.Printf("=== resize: %dx%d ===\n", hi.Width, hi.Height)
}
}
}
}()
// Tick timer.
ticker := time.NewTicker(20 * time.Millisecond)
go func() {
for range ticker.C {
flush()
}
}()
// Initial keepalive.
fmt.Println("Sending initial keepalive...")
transport.ForceNextSend()
flush()
// Resize.
fmt.Println("Sending resize 80x24...")
resize := mosh.MarshalUserMessage([]mosh.UserInstruction{
{Width: 80, Height: 24},
})
transport.SetPending(resize)
flush()
// Wait for initial output.
fmt.Println("Waiting 5 seconds for initial output...")
time.Sleep(5 * time.Second)
// Send ls + Enter.
fmt.Println("\nSending ls + Enter...")
keystroke := mosh.MarshalUserMessage([]mosh.UserInstruction{
{Keys: []byte("ls\r")},
})
transport.SetPending(keystroke)
flush()
// Wait for response.
time.Sleep(3 * time.Second)
fmt.Println("\nDone.")
ticker.Stop()
}
+36
View File
@@ -0,0 +1,36 @@
package mosh
import "time"
// Conn is a datagram-oriented connection used by the mosh transport.
// UDP, WebTransport, and other datagram transports implement this interface.
type Conn interface {
// Read reads a datagram. Returns the number of bytes read.
// Must respect deadlines set by SetReadDeadline.
Read(b []byte) (int, error)
// Write writes a datagram.
Write(b []byte) (int, error)
// SetReadDeadline sets the deadline for Read calls.
SetReadDeadline(t time.Time) error
// Close closes the connection.
Close() error
}
const (
// Direction bits in the 64-bit nonce header.
dirToServer = uint64(0) // client → server
dirToClient = uint64(1) << 63 // server → client
seqMask = ^(uint64(1) << 63) // lower 63 bits
// Minimum wire datagram: 8 (nonce) + 16 (tag) = 24 bytes.
minDatagram = 24
// Maximum payload per UDP datagram.
maxPayload = 16384
// Tick interval matches mosh's SEND_MINDELAY (8ms) for keystroke batching.
tickInterval = 8 * time.Millisecond
)
+22 -11
View File
@@ -5,9 +5,20 @@ import (
"strconv"
"unicode/utf8"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/vt"
"github.com/unixshells/vt-go"
)
// Cell attribute bitmasks (matching charmbracelet/ultraviolet values).
const (
attrBold = 1 << 0
attrFaint = 1 << 1
attrItalic = 1 << 2
attrBlink = 1 << 3
attrRapidBlink = 1 << 4
attrReverse = 1 << 5
attrConceal = 1 << 6
attrStrikethrough = 1 << 7
)
// Framebuffer represents a terminal screen state for mosh SSP.
@@ -163,11 +174,11 @@ func (fb *Framebuffer) fullRedraw() []byte {
buf = append(buf, "\r\n"...)
}
rowOff := y * fb.W
// Track trailing spaces to avoid sending them.
// Track trailing default-attr spaces to avoid sending them.
lastNonSpace := -1
for x := fb.W - 1; x >= 0; x-- {
c := &fb.Cells[rowOff+x]
if c.Rune != ' ' || c.Rune == 0 || c.Attr != (Attr{}) {
if (c.Rune != ' ' && c.Rune != 0) || c.Attr != (Attr{}) {
lastNonSpace = x
break
}
@@ -401,13 +412,13 @@ func SnapshotEmulator(emu *vt.Emulator, cursorVisible bool) *Framebuffer {
}
// 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.Bold = c.Style.Attrs&attrBold != 0
cell.Attr.Dim = c.Style.Attrs&attrFaint != 0
cell.Attr.Italic = c.Style.Attrs&attrItalic != 0
cell.Attr.Blink = c.Style.Attrs&attrBlink != 0
cell.Attr.Reverse = c.Style.Attrs&attrReverse != 0
cell.Attr.Strike = c.Style.Attrs&attrStrikethrough != 0
cell.Attr.Under = c.Style.Underline != ansi.UnderlineNone
cell.Attr.FG = convertColor(c.Style.Fg)
cell.Attr.BG = convertColor(c.Style.Bg)
+2 -2
View File
@@ -3,16 +3,16 @@ module github.com/unixshells/mosh-go
go 1.26.0
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/unixshells/vt-go v0.1.0
golang.org/x/crypto v0.49.0
golang.org/x/term v0.41.0
)
require (
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff // 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
+2 -2
View File
@@ -10,8 +10,6 @@ github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSg
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=
@@ -30,6 +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.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=
Executable
BIN
View File
Binary file not shown.
+15 -16
View File
@@ -1,3 +1,5 @@
//go:build !js
package mosh
import (
@@ -13,25 +15,11 @@ import (
"sync/atomic"
"time"
"github.com/charmbracelet/x/vt"
"github.com/unixshells/vt-go"
"github.com/creack/pty"
)
const (
// Direction bits in the 64-bit nonce header.
dirToServer = uint64(0) // client → server
dirToClient = uint64(1) << 63 // server → client
seqMask = ^(uint64(1) << 63) // lower 63 bits
// Minimum wire datagram: 8 (nonce) + 16 (tag) = 24 bytes.
minDatagram = 24
// Maximum payload per UDP datagram.
maxPayload = 16384
// Tick interval matches mosh's SEND_MINDELAY (8ms) for keystroke batching.
tickInterval = 8 * time.Millisecond
// Server gives up waiting for client after this.
associationTimeout = 60 * time.Second
@@ -270,6 +258,9 @@ func (s *Server) readIO(r io.Reader, out chan<- []byte) {
}
func (s *Server) mainLoopRW(rw io.Writer, resize func(cols, rows uint16), ioOutput <-chan []byte, userInput <-chan UserInstruction) {
// Wait for the first client datagram. If no client connects within
// 60 seconds, this was a failed setup — shut down to release resources.
deadline := time.After(associationTimeout)
for {
s.mu.Lock()
addr := s.clientAddr
@@ -280,6 +271,9 @@ func (s *Server) mainLoopRW(rw io.Writer, resize func(cols, rows uint16), ioOutp
select {
case <-s.done:
return
case <-deadline:
close(s.done)
return
case <-time.After(100 * time.Millisecond):
}
}
@@ -369,7 +363,9 @@ func (s *Server) Close() {
// 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) {
// Wait for client to connect before processing.
// Wait for the first client datagram. If no client connects within
// 60 seconds, this was a failed setup — shut down to release resources.
deadline := time.After(associationTimeout)
for {
s.mu.Lock()
addr := s.clientAddr
@@ -380,6 +376,9 @@ func (s *Server) mainLoop(ptyOutput <-chan []byte, userInput <-chan UserInstruct
select {
case <-s.done:
return
case <-deadline:
close(s.done)
return
case <-time.After(100 * time.Millisecond):
}
}
+2
View File
@@ -1,3 +1,5 @@
//go:build !js
package mosh
import (
+32 -4
View File
@@ -34,10 +34,12 @@ type Transport struct {
pendingDataAck bool // true = send ack ASAP (received data, not just ack)
// Incoming state — list of received state nums for old_num validation.
receivedNums []uint64 // ordered list of state nums we have
ackNum uint64 // latest received state num
sentAckNum uint64 // last ackNum we actually sent on wire
throwawayNum uint64 // oldest state we still hold
receivedNums []uint64 // ordered list of state nums we have
ackNum uint64 // latest received state num
sentAckNum uint64 // last ackNum we actually sent on wire
throwawayNum uint64 // oldest state we still hold
lastRecvOldNum uint64 // oldNum from most recently received diff
lastRecvNewNum uint64 // newNum from most recently received diff
// Sequence counter for the crypto layer (independent of SSP state numbering).
seqOut uint64
@@ -326,6 +328,10 @@ func (t *Transport) Recv(wire []byte) []byte {
t.receivedNums = filtered
}
// Track oldNum/newNum for state management.
t.lastRecvOldNum = ti.OldNum
t.lastRecvNewNum = ti.NewNum
// Add new state.
t.receivedNums = append(t.receivedNums, ti.NewNum)
@@ -359,6 +365,28 @@ func (t *Transport) SentNum() uint64 {
return t.sentNum
}
// LastRecvOldNum returns the oldNum from the most recently received diff.
func (t *Transport) LastRecvOldNum() uint64 {
t.mu.Lock()
defer t.mu.Unlock()
return t.lastRecvOldNum
}
// LastRecvNewNum returns the newNum from the most recently received diff.
func (t *Transport) LastRecvNewNum() uint64 {
t.mu.Lock()
defer t.mu.Unlock()
return t.lastRecvNewNum
}
// ThrowawayNum returns the server's throwaway number — states below this
// are no longer referenced by the server and can be safely pruned.
func (t *Transport) ThrowawayNum() uint64 {
t.mu.Lock()
defer t.mu.Unlock()
return t.throwawayNum
}
// LastRecv returns the time of the last received datagram.
func (t *Transport) LastRecv() time.Time {
t.mu.Lock()