Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,3 +444,43 @@ go run github.com/loov/goda@latest graph github.com/skycoin/skywire/... | dot -T
```

![Dependency Graph](docs/skywire-goda-graph.svg "github.com/skycoin/skywire Dependency Graph")

## Lines of Code

Made with [gocloc](https://github.com/hhatto/gocloc) (excludes `vendor/`, `node_modules/`, `.git/`):

```
gocloc --not-match-d='(vendor|node_modules|\.git)' .
```

```
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Go 1740 45601 65791 267754
Markdown 660 12807 39 40089
JSON 1453 380 0 36078
TypeScript 156 3825 6460 22780
HTML 103 1292 1221 15820
JavaScript 33 392 858 6811
Sass 90 1239 350 6529
BASH 41 365 701 1661
TOML 10 215 37 1656
Plain Text 6 342 0 1351
YAML 8 40 127 1094
Makefile 3 145 34 553
Protocol Buffers 1 66 385 349
Starlark 23 59 424 271
Nix 3 38 151 245
WiX 2 31 14 145
Batch 2 13 0 105
PowerShell 1 11 0 91
XML 2 0 0 51
C 1 14 24 46
Bourne Shell 5 14 0 43
CSS 3 4 10 32
Assembly 3 9 13 20
-------------------------------------------------------------------------------
TOTAL 4349 66902 76639 403574
-------------------------------------------------------------------------------
```
10 changes: 8 additions & 2 deletions cmd/apps/skychat/commands/cxo_tcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,14 @@ func startCXOGroup(ctx context.Context) error {
// zero, i.e. never connected). Once a peer connects and its first Root
// lands, lastInbound is set and the treestore watchdog takes over
// quiet-reconnects, so we stop poking it.
//
// Cadence is short (a few seconds): two members that start close together
// each fail the other's initial dial (peer not listening yet), so at the
// old 15s cadence a late joiner's first message took ~30-40s to arrive
// (measured). The loop skips already-attached peers, so once everyone is
// connected it idles cheaply — a fast tick only costs work while warming up.
go func() {
t := time.NewTicker(15 * time.Second)
t := time.NewTicker(3 * time.Second)
defer t.Stop()
for {
select {
Expand All @@ -263,7 +269,7 @@ func startCXOGroup(ctx context.Context) error {
if ppk == pk || !sess.PeerLastInbound(ppk).IsZero() {
continue
}
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
rctx, cancel := context.WithTimeout(ctx, 10*time.Second)
if rerr := sess.ReconnectPeer(rctx, ppk); rerr != nil {
appLog("skychat: cxo-group reconnect %s: %v", ppk, rerr)
}
Expand Down
29 changes: 11 additions & 18 deletions cmd/apps/skychat/commands/sendack.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,21 @@
package commands

import (
"encoding/json"
"sync"
"time"

"github.com/skycoin/skywire/pkg/skychat/message"
)

// chatEnvelope is the on-the-wire shape for both chat-msg (carries
// body) and chat-ack (just id). Fields are encoded omitempty so the
// two share one Go struct without leaking irrelevant fields.
type chatEnvelope struct {
Type string `json:"type"`
ID string `json:"id"`
Body string `json:"body,omitempty"`
Ack bool `json:"ack,omitempty"` // chat-msg only: request ack-on-receipt
}
// chatEnvelope / chatTypeMsg / chatTypeAck alias the shared skychat wire types
// (pkg/skychat/message) so this file's ack routing and the envelope construction
// in skychat.go keep their familiar local names while the on-the-wire shape lives
// in exactly one place.
type chatEnvelope = message.Envelope

const (
chatTypeMsg = "chat-msg"
chatTypeAck = "chat-ack"
chatTypeMsg = message.TypeMsg
chatTypeAck = message.TypeAck
)

// ackWaiters routes an inbound chat-ack envelope back to the
Expand Down Expand Up @@ -107,12 +104,8 @@ func deliverAck(id string) {
// "not an envelope" so plain-text JSON-looking chat (e.g. someone
// typing literal {} into a chat window) still reaches its peer.
func tryHandleChatEnvelope(payload []byte, peerPKHex string, sendAck func(id string)) (handled bool, body string, id string) {
trimmed := bytesTrimSpace(payload)
if len(trimmed) == 0 || trimmed[0] != '{' {
return false, "", ""
}
var env chatEnvelope
if err := json.Unmarshal(trimmed, &env); err != nil {
env, ok := message.ParseEnvelope(payload)
if !ok {
return false, "", ""
}
switch env.Type {
Expand Down
149 changes: 15 additions & 134 deletions cmd/apps/skychat/commands/skychat.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net"
Expand Down Expand Up @@ -38,73 +37,29 @@ import (
"github.com/skycoin/skywire/pkg/logging"
"github.com/skycoin/skywire/pkg/netutil"
"github.com/skycoin/skywire/pkg/routing"
"github.com/skycoin/skywire/pkg/skychat/message"
"github.com/skycoin/skywire/pkg/skyenv"
"github.com/skycoin/skywire/pkg/visor"
)

var r = netutil.NewRetrier(nil, 50*time.Millisecond, netutil.DefaultMaxBackoff, 5, 2)

// Wire-protocol constants for skychat's peer-to-peer messages.
// skychat's peer-to-peer wire format — length-prefixed frames plus the
// chat-msg/chat-ack envelope — now lives in one place: pkg/skychat/message.
// framedConn is a thin alias over message.Conn so the many call sites in this
// file keep their local name; newFramedConn wraps a raw appnet conn with framing
// and a write mutex (needed because the /message handler and the pair-control
// sender can race to write the same conn — interleaving one frame's length prefix
// with another's payload would desync the receiver permanently).
//
// Pre-2026-05-12 the protocol was "one conn.Write = one conn.Read":
// every message was sent as a raw byte slice and the receiver
// assumed each Read returned exactly one message. That held for
// dmsg (noise-framed up to 4 KB) but broke on the skynet route
// path — appnet.directConn wraps transport.VStream, which is a
// TCP-style stream that can split a single Write across multiple
// Reads at arbitrary boundaries depending on route MTU. A
// 600-byte chat message would arrive as two "messages" on the
// receiver and the second half would surface as a separate chat
// entry, looking to operators like the message was truncated.
//
// New protocol: length-prefixed frames. Each message is a 4-byte
// big-endian length followed by exactly that many bytes of
// payload. Old binaries can no longer talk to new ones — peers
// must update together. The pair-control envelope (JSON `{type:
// "pair-invite" | ...}`) keeps the same on-the-wire bytes; only
// the framing around it changed.
const skychatMaxFrameSize = 64 * 1024

// framedConn wraps an appnet conn with length-prefixed framing
// and a write mutex. The write mutex matters because two
// callers (the HTTP /message handler and the pair-control
// sender) can race to write to the same underlying conn — and
// with framing, interleaving the length prefix of one message
// with the payload of another would desync the receiver
// permanently. The read path has a single owner (handleConn)
// so no read mutex is needed.
type framedConn struct {
net.Conn
writeMu sync.Mutex
}

func newFramedConn(c net.Conn) *framedConn { return &framedConn{Conn: c} }
// History: pre-2026-05-12 the protocol was "one Write = one Read" (a raw byte
// slice per message). That held on dmsg (noise-framed) but broke on skynet
// routes, where a VStream can split one Write across several Reads at arbitrary
// boundaries — a 600-byte message arrived as two chat entries. The length-prefixed
// frame fixed that; old unframed binaries can't talk to framed ones.
type framedConn = message.Conn

// WriteFrame writes a length-prefixed message. Returns an error
// if the payload is empty or exceeds skychatMaxFrameSize.
//
// Unbounded — if the underlying net.Conn's Write blocks (peer not
// draining, transport stuck), the call blocks indefinitely and the
// caller's request goroutine is wedged. messageHandler uses
// WriteFrameDeadline below instead so a slow peer can't pin the
// /message handler forever.
func (c *framedConn) WriteFrame(payload []byte) error {
if len(payload) == 0 {
return errors.New("skychat: empty payload")
}
if len(payload) > skychatMaxFrameSize {
return fmt.Errorf("skychat: payload %d > max %d", len(payload), skychatMaxFrameSize)
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload))) //nolint:gosec
if _, err := c.Conn.Write(hdr[:]); err != nil {
return err
}
_, err := c.Conn.Write(payload)
return err
}
func newFramedConn(c net.Conn) *framedConn { return message.NewConn(c) }

// messageWriteTimeout bounds a single WriteFrame call from the
// /message HTTP handler. Picked above the typical successful-send
Expand All @@ -126,57 +81,6 @@ func (c *framedConn) WriteFrame(payload []byte) error {
// caller's send is freed.
const messageWriteTimeout = 5 * time.Second

// WriteFrameDeadline is the bounded variant used from
// messageHandler. Sets a write deadline on the underlying net.Conn
// for the duration of this Write call, then resets it on the way
// out — so the deadline scope is exactly the framed write and
// future calls aren't accidentally pre-expired by a lingering
// deadline. A net.Conn-level timeout surfaces as an error from
// Write whose Timeout() method returns true, which the caller can
// distinguish from a connection-reset or peer-close.
//
// timeout must be > 0; pass 0 to fall back to plain WriteFrame
// (no deadline). The reset uses time.Time{} which net.Conn
// documents as "no deadline".
func (c *framedConn) WriteFrameDeadline(payload []byte, timeout time.Duration) error {
if timeout <= 0 {
return c.WriteFrame(payload)
}
// Acquire writeMu BEFORE setting the deadline so the deadline
// scope tracks the actual on-wire write rather than including
// time spent waiting behind a concurrent same-peer writer. If
// the wait itself wedges, the caller's HTTP context-deadline
// is the outer ceiling.
c.writeMu.Lock()
defer c.writeMu.Unlock()

if err := c.Conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
// SetWriteDeadline failure is rare on a healthy net.Conn;
// fall through to a best-effort unbounded write rather
// than blocking the message entirely on a metadata error.
// Subsequent SetWriteDeadline(time.Time{}) reset on the
// way out is also best-effort.
_ = err //nolint:errcheck
}
defer func() {
_ = c.Conn.SetWriteDeadline(time.Time{}) //nolint:errcheck
}()

if len(payload) == 0 {
return errors.New("skychat: empty payload")
}
if len(payload) > skychatMaxFrameSize {
return fmt.Errorf("skychat: payload %d > max %d", len(payload), skychatMaxFrameSize)
}
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload))) //nolint:gosec
if _, err := c.Conn.Write(hdr[:]); err != nil {
return err
}
_, err := c.Conn.Write(payload)
return err
}

// dialAndCache dials the peer at addr (using the package retrier),
// wraps the raw conn in framing, registers it in the conns cache,
// and starts the read loop. Used both from the cache-miss path in
Expand Down Expand Up @@ -256,29 +160,6 @@ func tryNetworkFallback(ctx context.Context, pk cipher.PubKey, currentNet appnet
return conn, altAddr
}

// ReadFrame reads exactly one length-prefixed message. Rejects
// frames over skychatMaxFrameSize so a malicious or out-of-sync
// peer can't allocate gigabytes of memory by claiming a giant
// length.
func (c *framedConn) ReadFrame() ([]byte, error) {
var hdr [4]byte
if _, err := io.ReadFull(c.Conn, hdr[:]); err != nil {
return nil, err
}
length := binary.BigEndian.Uint32(hdr[:])
if length == 0 {
return nil, errors.New("skychat: zero-length frame")
}
if length > skychatMaxFrameSize {
return nil, fmt.Errorf("skychat: frame %d > max %d (peer running old unframed protocol?)", length, skychatMaxFrameSize)
}
payload := make([]byte, length)
if _, err := io.ReadFull(c.Conn, payload); err != nil {
return nil, err
}
return payload, nil
}

var (
addr string
appCl *app.Client
Expand Down
Loading
Loading