feat(ws): release generic WebSocket client and server v0.31.0-preview

This commit is contained in:
2026-10-29 19:20:12 +08:00
parent 19ee9746e9
commit 3272974bc1
58 changed files with 7654 additions and 21 deletions

98
ws/rpc/README.md Normal file
View File

@@ -0,0 +1,98 @@
# WebSocket 双向调用与订阅
`ws/rpc` 是可选的 JSON 消息协议。普通 `ws` 的文本/二进制消息不依赖此协议。双端必须协商 `rpc.Subprotocol`(`go-omni.rpc.v1`),客户端和服务端都可发起调用、处理调用和发送事件。它不承诺离线消息、断线重放、事务回滚或 exactly-once。
## 调用
```go
peer, err := rpc.New(rpc.Config{})
if err != nil { return err }
err = peer.Handle("echo", func(ctx context.Context, params json.RawMessage) (any, error) {
return params, nil
})
if err != nil { return err }
client, err := ws.NewClient(ws.ClientConfig{
Subprotocols: []string{rpc.Subprotocol}, RequireSubprotocol: true,
})
if err != nil { return err }
session, err := client.Dial(ctx, endpoint, peer.Handlers())
if err != nil { return err }
if err := peer.WaitReady(ctx); err != nil { return err }
var result map[string]any
err = peer.Call(ctx, "echo", map[string]any{"message": "hello"}, &result)
// Notify 确认本地写入完成,不等待对端处理,也不返回业务执行结果。
err = peer.Notify(ctx, "changed", map[string]any{"revision": 2})
// 退出时使用有界关闭 context,并检查返回值。
err = client.Shutdown(shutdownCtx)
_ = session
```
双向调用允许 Handler 内发起另一个 `Call`。接收路径立即处理回复,把请求分派到有上限的任务中,避免串行消息回调形成双向等待。路由在连接建立前通过 `Handle` 注册,重复方法或连接后注册返回错误。方法最多 128 字节,使用 ASCII 字母、数字及 `._-/:`。
服务端用 `ws.ServerConfig.Prepare` 为每次握手创建独立 Peer 并返回 `peer.Handlers()`;客户端自动重连用 `ws.ClientConfig.Prepare` 为每次连接创建独立 Peer。旧 Peer 永不绑定新连接。将新 Peer 交给应用后,先 `WaitReady`,再发起调用或恢复订阅。**不要在 `OnOpen` 中同步 Call/Restore**,因为 Session 在 OnOpen 返回后才分派回复。
完整真实 TCP 示例和退出流程见 [`examples/websocketRPC`](../../examples/websocketRPC)。
## 协议和错误
```json
{"v":1,"type":"call","id":"1","method":"echo","params":{"message":"hello"},"timeoutMs":30000}
{"v":1,"type":"reply","id":"1","result":{"message":"hello"}}
{"v":1,"type":"reply","id":"2","error":{"code":"DENIED","message":"access denied"}}
{"v":1,"type":"event","method":"changed","params":{"revision":2}}
{"v":1,"type":"cancel","id":"1"}
```
一条 WS 文本消息对应一个 envelope。协议版本、类型、字段组合、方法名和请求 ID 都校验;未知字段、重复 JSON 键(包括嵌套 params/result)、超过 32 层的 JSON、无效 UTF-8、二进制消息及超限消息被拒绝。收到重复的在途请求 ID 视为协议错误;迟到或重复回复丢弃,不创建新的 pending。未知调用回复 `METHOD_NOT_FOUND`;未知事件为协议错误,会终止连接。
`Call` 的执行预算取调用方 context 与 `Config.CallTimeout` 的较小值,预算毫秒数发送给对端,接收方再次按自己的配置裁剪。取消时尽力发送 cancel;取消消息入队失败不会无限等待,接收方仍有自己的超时。超时不表示远端业务已撤销。`Call` 与 `Notify` 都不会自动重试。
仅显式返回 `*rpc.Error{Code, Message}` 才会向对端发送自定义文本。普通错误、编码错误和 panic 使用通用 `INTERNAL`,不发送实现细节。超时/取消分别映射为 `DEADLINE_EXCEEDED` / `CANCELED`。事件处理无响应,可通过 `Config.OnEventError` 在本地观察失败;回调纳入排空等待。对端的公开错误仍是外部输入,日志不要自动输出其正文。
## 有界执行和退出
| 配置 | 默认 | 含义 |
| --- | --- | --- |
| `MaxMessageBytes` | 1 MiB | 完整 JSON envelope 的大小上限,应与底层 WS 消息限额匹配 |
| `MaxPending` | 128 | 本端等待响应的调用上限 |
| `MaxConcurrent` | 32 | 同时执行的入站调用及事件总数 |
| `CallTimeout` | 30s | 入站业务及出站 Call 的最大预算 |
| `WriteTimeout` | 5s | 事件和响应的写入期限 |
超出 pending 返回 `ErrBusy`;对端超出入站执行容量时,调用收到 `BUSY`,事件导致连接关闭,避免静默丢失。handler 必须响应 context;Go 不能强杀不退出的业务 goroutine。`Stop` 停止接收协议工作并取消 pending/incoming;`Wait` 等待所有入站 handler 和事件错误回调实际退出;`Shutdown` 额外发起 Session 关闭,但 Session 的最终关闭结果仍通过其 `Wait` 或所属 Client/Server.Shutdown 检查。
`Peer.Handlers().OnClose` 将上述任务接入 Session 完成屏障;若自定义包装 Handlers,必须保留这一回调。不要从本 Peer 的 Handler 内调用 `Wait/Shutdown`,可以调用 `Stop` 请求退出。关闭超时后,按框架生命周期规则保留仍被业务任务使用的依赖。
## 订阅和重连恢复
`Subscriptions` 管理本地**期望订阅**;服务端使用普通 RPC 路由处理加入/退出,执行自己的授权并可结合 `ws/hub`。默认方法为 `rpc.subscribe` / `rpc.unsubscribe`,可配置;此模块不预设用户、租户或主题权限。
```go
subscriptions, err := rpc.NewSubscriptions(rpc.SubscriptionConfig{})
if err != nil { return err }
// nil Peer 仅保存期望状态,适合首次连接前配置。
err = subscriptions.Subscribe(ctx, nil, "updates", map[string]any{"filter": "all"})
// 每次连接创建新 Peer,WaitReady 后从 OnOpen 之外显式恢复。
restored, err := subscriptions.Restore(ctx, newPeer)
// 在线新增/移除同时发出 RPC 并等待确认。
err = subscriptions.Subscribe(ctx, newPeer, "metrics", nil)
err = subscriptions.Unsubscribe(ctx, newPeer, "updates")
keys, err := subscriptions.Keys(ctx)
_, _ = restored, keys
```
订阅请求形如 `{"key":"updates","params":{...}}`;取消请求形如 `{"key":"updates"}`。服务端应将 key 的授权绑定到已验证的连接身份,每次恢复重新验证。相同连接中同一 key 的订阅应幂等;同 key 参数改变表示更新期望条件,由服务端定义其通用处理规则。
本地 Subscribe 在发送前保存期望状态,即使远端拒绝仍保留,应用必须检查错误并决定移除或以后重试;Unsubscribe 在发送前移除本地期望状态,若确认失败,当前连接仍可能收到事件,直至远端移除或连接关闭。Restore 按 key 排序,遇首个错误停止并返回已确认数量。操作串行且等待可通过 context 取消,防止并发恢复和移除颠倒状态。
默认最多 256 个订阅,每个 params 最多 16 KiB。key 是最多 256 字节的 UTF-8 不透明字符串,禁止 CR/LF/NUL。恢复只恢复订阅关系,断线期间事件不会补发。若业务需要历史事件,使用业务游标和持久化消息机制另行实现。
## 验证
```sh
go test -race ./ws/rpc
go test ./ws/rpc -run '^$' -fuzz FuzzProtocolDecode -fuzztime=10s
go test ./ws/rpc -run '^$' -bench . -benchmem
```
单元测试覆盖双向嵌套调用、并发上限、取消、迟到回复、重复键/层数/长度、错误脱敏、业务退出屏障、订阅恢复和真实 TCP 握手/关闭。`BenchmarkPeerCall` 是内存传输上的协议开销基准,不代表真实网络吞吐;`BenchmarkProtocolDecode` 测协议解析与严格输入校验。

67
ws/rpc/config.go Normal file
View File

@@ -0,0 +1,67 @@
// Package rpc implements an optional, versioned JSON protocol over ws sessions.
// Both endpoints may issue calls and events. It does not provide durable delivery.
package rpc
import (
"context"
"errors"
"time"
)
const Subprotocol = "go-omni.rpc.v1"
var (
ErrClosed = errors.New("rpc: closed")
ErrNotConnected = errors.New("rpc: not connected")
ErrBusy = errors.New("rpc: capacity reached")
ErrProtocol = errors.New("rpc: invalid protocol message")
)
// Config bounds allocations, concurrency, and the lifetime of incoming work.
// Zero fields select defaults. A canceled handler must return before Wait succeeds.
type Config struct {
MaxMessageBytes int `json:"maxMessageBytes"`
MaxPending int `json:"maxPending"`
MaxConcurrent int `json:"maxConcurrent"`
CallTimeout time.Duration `json:"callTimeout"`
WriteTimeout time.Duration `json:"writeTimeout"`
// OnEventError observes local notification failures; they have no reply on
// the wire. This callback runs within the handler completion barrier.
OnEventError func(context.Context, error) `json:"-"`
}
func DefaultConfig() Config {
return Config{MaxMessageBytes: 1 << 20, MaxPending: 128, MaxConcurrent: 32, CallTimeout: 30 * time.Second, WriteTimeout: 5 * time.Second}
}
func (c Config) Validate() error { _, err := normalize(c); return err }
func normalize(c Config) (Config, error) {
if c.MaxMessageBytes < 0 || c.MaxMessageBytes > 16<<20 || c.MaxPending < 0 || c.MaxPending > 65536 || c.MaxConcurrent < 0 || c.MaxConcurrent > 4096 || c.CallTimeout < 0 || c.CallTimeout > 24*time.Hour || c.WriteTimeout < 0 || c.WriteTimeout > time.Minute {
return c, errors.New("rpc: configuration exceeds supported bounds")
}
d := DefaultConfig()
if c.MaxMessageBytes == 0 {
c.MaxMessageBytes = d.MaxMessageBytes
}
if c.MaxPending == 0 {
c.MaxPending = d.MaxPending
}
if c.MaxConcurrent == 0 {
c.MaxConcurrent = d.MaxConcurrent
}
if c.CallTimeout == 0 {
c.CallTimeout = d.CallTimeout
}
if c.WriteTimeout == 0 {
c.WriteTimeout = d.WriteTimeout
}
return c, nil
}
// Error is an explicitly public error. Only this type's code/message may be
// sent to the other endpoint; all other handler errors become INTERNAL.
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (e *Error) Error() string { return "rpc: " + e.Code }

424
ws/rpc/peer.go Normal file
View File

@@ -0,0 +1,424 @@
package rpc
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"sync"
"time"
"git.yuntuo.hk/k2safe/go-omni/ws"
)
type Handler func(context.Context, json.RawMessage) (any, error)
type transport interface {
Context() context.Context
Send(context.Context, ws.Message) error
TrySend(ws.Message) error
Close(int, string) error
Abort()
}
// Peer belongs to exactly one physical session. Construct a new Peer on every
// reconnect. Register routes before passing Handlers to a client/server.
type Peer struct {
mu sync.Mutex
cfg Config
conn transport
ctx context.Context
cancel context.CancelFunc
routes map[string]Handler
pending map[string]chan envelope
incoming map[string]context.CancelFunc
seq uint64
active int
closed bool
done chan struct{}
ready chan struct{}
doneOnce sync.Once
}
func New(cfg Config) (*Peer, error) {
cfg, err := normalize(cfg)
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
return &Peer{cfg: cfg, ctx: ctx, cancel: cancel, routes: make(map[string]Handler), pending: make(map[string]chan envelope), incoming: make(map[string]context.CancelFunc), done: make(chan struct{}), ready: make(chan struct{})}, nil
}
func (p *Peer) Handle(method string, handler Handler) error {
if !validName(method, 128) || handler == nil {
return errors.New("rpc: valid method and handler required")
}
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return ErrClosed
}
if p.conn != nil {
return errors.New("rpc: register methods before binding")
}
if _, ok := p.routes[method]; ok {
return errors.New("rpc: duplicate method")
}
p.routes[method] = handler
return nil
}
// Handlers integrates protocol work into the Session completion barrier.
// OnClose waits for all accepted handlers, including handlers ignoring cancel.
// Owners must use a bounded Session.Wait/Shutdown and retain dependencies if it
// times out. Do not call Wait or Shutdown from a registered handler itself.
func (p *Peer) Handlers() ws.Handlers {
return ws.Handlers{
OnOpen: func(_ context.Context, s *ws.Session) error {
if s.Subprotocol() != Subprotocol {
return ErrProtocol
}
return p.bind(s)
},
OnMessage: func(_ context.Context, _ *ws.Session, m ws.Message) error { return p.receive(m) },
OnClose: func(_ *ws.Session, _ error) { p.Stop(); _ = p.Wait(context.Background()) },
}
}
func (p *Peer) bind(c transport) error {
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return ErrClosed
}
if p.conn != nil {
p.mu.Unlock()
return errors.New("rpc: peer already bound")
}
p.cancel()
p.ctx, p.cancel = context.WithCancel(c.Context())
p.conn = c
close(p.ready)
ctx := p.ctx
p.mu.Unlock()
context.AfterFunc(ctx, p.Stop)
return nil
}
// Stop cancels pending calls and incoming handlers without waiting for them.
// It does not own the underlying Session; Shutdown additionally closes it.
func (p *Peer) Stop() {
p.mu.Lock()
if !p.closed {
p.closed = true
p.cancel()
for _, cancel := range p.incoming {
cancel()
}
}
if p.active == 0 {
p.doneOnce.Do(func() { close(p.done) })
}
p.mu.Unlock()
}
func (p *Peer) Done() <-chan struct{} { return p.done }
// WaitReady waits for OnOpen to bind the transport. Call and Restore should run
// outside OnOpen: replies are delivered only after that callback returns.
func (p *Peer) WaitReady(ctx context.Context) error {
select {
case <-p.ready:
p.mu.Lock()
closed := p.closed
p.mu.Unlock()
if closed {
return ErrClosed
}
return nil
case <-p.done:
return ErrClosed
case <-ctx.Done():
return ctx.Err()
}
}
func (p *Peer) Wait(ctx context.Context) error {
select {
case <-p.done:
return nil
default:
}
select {
case <-p.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (p *Peer) Shutdown(ctx context.Context) error {
p.Stop()
p.mu.Lock()
c := p.conn
p.mu.Unlock()
if c != nil {
_ = c.Close(1000, "")
}
return p.Wait(ctx)
}
// Call waits for the peer's reply, bounded by ctx and Config.CallTimeout.
// Cancellation is best effort on the wire; a timeout never proves rollback.
func (p *Peer) Call(ctx context.Context, method string, input, out any) error {
if !validName(method, 128) {
return errors.New("rpc: invalid method")
}
params, err := json.Marshal(input)
if err != nil {
return fmt.Errorf("rpc: encode input: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, p.cfg.CallTimeout)
defer cancel()
if err := ctx.Err(); err != nil {
return err
}
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return ErrClosed
}
if p.conn == nil {
p.mu.Unlock()
return ErrNotConnected
}
if len(p.pending) >= p.cfg.MaxPending {
p.mu.Unlock()
return ErrBusy
}
p.seq++
id := strconv.FormatUint(p.seq, 36)
ch := make(chan envelope, 1)
p.pending[id] = ch
c, work := p.conn, p.ctx
p.mu.Unlock()
defer func() { p.mu.Lock(); delete(p.pending, id); p.mu.Unlock() }()
deadline, _ := ctx.Deadline()
budget := max(int64(1), time.Until(deadline).Milliseconds())
if err = p.send(ctx, c, envelope{Version: 1, Type: "call", ID: id, Method: method, Params: params, TimeoutMS: budget}); err != nil {
return err
}
select {
case reply := <-ch:
if reply.Error != nil {
return reply.Error
}
if out == nil {
return nil
}
return json.Unmarshal(reply.Result, out)
case <-ctx.Done():
// Never block a caller waiting for room to send cancellation. The remote
// handler is independently bounded by the transmitted deadline.
_ = p.try(c, envelope{Version: 1, Type: "cancel", ID: id})
return ctx.Err()
case <-work.Done():
return ErrClosed
}
}
// Notify confirms a local write, not remote processing. Notifications are
// handled with the same concurrency and timeout limits as incoming calls.
func (p *Peer) Notify(ctx context.Context, method string, input any) error {
if !validName(method, 128) {
return errors.New("rpc: invalid method")
}
params, err := json.Marshal(input)
if err != nil {
return err
}
p.mu.Lock()
c, closed := p.conn, p.closed
p.mu.Unlock()
if closed {
return ErrClosed
}
if c == nil {
return ErrNotConnected
}
ctx, cancel := context.WithTimeout(ctx, p.cfg.WriteTimeout)
defer cancel()
return p.send(ctx, c, envelope{Version: 1, Type: "event", Method: method, Params: params})
}
func (p *Peer) encode(e envelope) (ws.Message, error) {
data, err := json.Marshal(e)
if err != nil {
return ws.Message{}, err
}
if len(data) > p.cfg.MaxMessageBytes {
return ws.Message{}, ws.ErrMessageTooLarge
}
return ws.Message{Type: ws.Text, Data: data}, nil
}
func (p *Peer) send(ctx context.Context, c transport, e envelope) error {
m, err := p.encode(e)
if err != nil {
return err
}
return c.Send(ctx, m)
}
func (p *Peer) try(c transport, e envelope) error {
m, err := p.encode(e)
if err != nil {
return err
}
return c.TrySend(m)
}
func (p *Peer) receive(m ws.Message) error {
if m.Type != ws.Text {
return ErrProtocol
}
e, err := decode(m.Data, p.cfg.MaxMessageBytes)
if err != nil {
return err
}
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return ErrClosed
}
if p.conn == nil {
p.mu.Unlock()
return ErrNotConnected
}
switch e.Type {
case "reply":
if ch := p.pending[e.ID]; ch != nil {
select {
case ch <- e:
default:
}
}
p.mu.Unlock()
return nil
case "cancel":
if cancel := p.incoming[e.ID]; cancel != nil {
cancel()
}
p.mu.Unlock()
return nil
}
c := p.conn
if e.Type == "call" {
if _, exists := p.incoming[e.ID]; exists {
p.mu.Unlock()
return ErrProtocol
}
}
handler := p.routes[e.Method]
if handler == nil {
p.mu.Unlock()
if e.Type == "event" {
return ErrProtocol
}
return p.try(c, envelope{Version: 1, Type: "reply", ID: e.ID, Error: &Error{"METHOD_NOT_FOUND", "method not found"}})
}
if p.active >= p.cfg.MaxConcurrent {
p.mu.Unlock()
if e.Type == "event" {
return ErrBusy
}
return p.try(c, envelope{Version: 1, Type: "reply", ID: e.ID, Error: &Error{"BUSY", "peer capacity reached"}})
}
timeout := p.cfg.CallTimeout
if e.TimeoutMS > 0 {
timeout = min(timeout, time.Duration(e.TimeoutMS)*time.Millisecond)
}
ctx, cancel := context.WithTimeout(p.ctx, timeout)
if e.Type == "call" {
p.incoming[e.ID] = cancel
}
p.active++
p.mu.Unlock()
go p.execute(ctx, cancel, c, e, handler)
return nil
}
func invoke(ctx context.Context, h Handler, params json.RawMessage) (value any, err error) {
defer func() {
if recover() != nil {
value = nil
err = errors.New("rpc: handler panic")
}
}()
return h(ctx, params)
}
func publicError(err error) (result *Error) {
result = &Error{"INTERNAL", "request failed"}
defer func() {
if recover() != nil {
result = &Error{"INTERNAL", "request failed"}
}
}()
if errors.Is(err, context.DeadlineExceeded) {
return &Error{"DEADLINE_EXCEEDED", "request deadline exceeded"}
}
if errors.Is(err, context.Canceled) {
return &Error{"CANCELED", "request canceled"}
}
var exposed *Error
if errors.As(err, &exposed) && validError(exposed) {
return &Error{exposed.Code, exposed.Message}
}
return &Error{"INTERNAL", "request failed"}
}
func marshalResult(value any) (raw json.RawMessage, err error) {
defer func() {
if recover() != nil {
raw = nil
err = errors.New("rpc: result encoding failed")
}
}()
return json.Marshal(value)
}
func (p *Peer) execute(ctx context.Context, cancel context.CancelFunc, c transport, e envelope, h Handler) {
defer func() {
cancel()
p.mu.Lock()
delete(p.incoming, e.ID)
p.active--
if p.closed && p.active == 0 {
p.doneOnce.Do(func() { close(p.done) })
}
p.mu.Unlock()
}()
value, err := invoke(ctx, h, e.Params)
if e.Type == "event" {
if err != nil && p.cfg.OnEventError != nil {
func() { defer func() { _ = recover() }(); p.cfg.OnEventError(ctx, err) }()
}
return
}
if err == nil {
err = ctx.Err()
}
reply := envelope{Version: 1, Type: "reply", ID: e.ID}
if err == nil {
reply.Result, err = marshalResult(value)
}
if err != nil {
reply.Result = nil
reply.Error = publicError(err)
}
writeCtx, writeCancel := context.WithTimeout(p.ctx, p.cfg.WriteTimeout)
defer writeCancel()
if err = p.send(writeCtx, c, reply); errors.Is(err, ws.ErrMessageTooLarge) {
reply.Result = nil
reply.Error = &Error{"RESULT_TOO_LARGE", "result exceeds message limit"}
err = p.send(writeCtx, c, reply)
}
if err != nil {
c.Abort()
}
}

409
ws/rpc/peer_test.go Normal file
View File

@@ -0,0 +1,409 @@
package rpc
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/ws"
)
type memoryTransport struct {
ctx context.Context
other *Peer
}
func (m *memoryTransport) Context() context.Context { return m.ctx }
func (m *memoryTransport) Send(ctx context.Context, msg ws.Message) error {
if err := ctx.Err(); err != nil {
return err
}
return m.other.receive(msg)
}
func (m *memoryTransport) TrySend(msg ws.Message) error { return m.other.receive(msg) }
func (m *memoryTransport) Close(int, string) error { return nil }
func (m *memoryTransport) Abort() { m.other.Stop() }
func pair(t testing.TB, cfg Config, setup func(*Peer, *Peer)) (*Peer, *Peer) {
t.Helper()
a, err := New(cfg)
if err != nil {
t.Fatal(err)
}
b, err := New(cfg)
if err != nil {
t.Fatal(err)
}
if setup != nil {
setup(a, b)
}
ctx, cancel := context.WithCancel(context.Background())
if err = a.bind(&memoryTransport{ctx, b}); err != nil {
t.Fatal(err)
}
if err = b.bind(&memoryTransport{ctx, a}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
a.Stop()
b.Stop()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := a.Wait(ctx); err != nil {
t.Error(err)
}
if err := b.Wait(ctx); err != nil {
t.Error(err)
}
})
return a, b
}
func handle(t testing.TB, p *Peer, name string, h Handler) {
t.Helper()
if err := p.Handle(name, h); err != nil {
t.Fatal(err)
}
}
func TestBidirectionalNestedCalls(t *testing.T) {
a, _ := pair(t, Config{}, func(a, b *Peer) {
handle(t, a, "double", func(_ context.Context, v json.RawMessage) (any, error) {
var n int
_ = json.Unmarshal(v, &n)
return n * 2, nil
})
handle(t, b, "nested", func(ctx context.Context, v json.RawMessage) (any, error) {
var n int
_ = json.Unmarshal(v, &n)
var doubled int
err := b.Call(ctx, "double", n, &doubled)
return doubled + 1, err
})
})
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
var result int
if err := a.Call(context.Background(), "nested", n, &result); err != nil || result != n*2+1 {
t.Errorf("result %d, error %v", result, err)
}
}(i)
}
wg.Wait()
}
func TestCancellationBusyAndCompletionBarrier(t *testing.T) {
entered := make(chan struct{})
exit := make(chan struct{})
release := make(chan struct{})
a, b := pair(t, Config{MaxConcurrent: 1}, func(a, b *Peer) {
handle(t, b, "block", func(ctx context.Context, _ json.RawMessage) (any, error) {
close(entered)
<-ctx.Done()
close(exit)
<-release
return nil, ctx.Err()
})
})
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
result := make(chan error, 1)
go func() { result <- a.Call(ctx, "block", nil, nil) }()
<-entered
if err := a.Call(context.Background(), "block", nil, nil); err == nil {
t.Fatal("capacity not enforced")
} else {
var pub *Error
if !errors.As(err, &pub) || pub.Code != "BUSY" {
t.Fatal(err)
}
}
if err := <-result; !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
<-exit
b.Stop()
wait, c := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer c()
if err := b.Wait(wait); !errors.Is(err, context.DeadlineExceeded) {
t.Fatal("handler not joined", err)
}
close(release)
if err := b.Wait(context.Background()); err != nil {
t.Fatal(err)
}
}
func TestErrorsDoNotExposeImplementation(t *testing.T) {
a, _ := pair(t, Config{}, func(a, b *Peer) {
handle(t, b, "private", func(context.Context, json.RawMessage) (any, error) { return nil, errors.New("password=secret") })
handle(t, b, "panic", func(context.Context, json.RawMessage) (any, error) { panic("token=private") })
handle(t, b, "public", func(context.Context, json.RawMessage) (any, error) {
return nil, &Error{"DENIED", "public explanation"}
})
})
for _, name := range []string{"private", "panic", "missing", "public"} {
err := a.Call(context.Background(), name, nil, nil)
var pub *Error
if !errors.As(err, &pub) {
t.Fatal(err)
}
if strings.Contains(pub.Message, "secret") || strings.Contains(pub.Message, "private") {
t.Fatal("leaked error", pub.Message)
}
if name == "public" && pub.Message != "public explanation" {
t.Fatal(pub)
}
}
}
func TestReplyLateDuplicateAndProtocolLimits(t *testing.T) {
p, _ := New(Config{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
q, _ := New(Config{})
_ = p.bind(&memoryTransport{ctx, q})
defer p.Stop()
// Late and duplicated replies must not block the reader or create allocations.
for i := 0; i < 10; i++ {
if err := p.receive(ws.Message{Type: ws.Text, Data: []byte(`{"v":1,"type":"reply","id":"old","result":null}`)}); err != nil {
t.Fatal(err)
}
}
invalid := []string{
`{"v":2,"type":"event","method":"x","params":null}`,
`{"v":1,"type":"call","id":"x","id":"y","method":"x","params":null}`,
`{"v":1,"type":"event","method":"x","params":{"role":1,"role":2}}`,
`{"v":1,"type":"reply","id":"a","result":null,"error":{"code":"BAD","message":"x"}}`,
`{"v":1,"type":"cancel","id":"a","params":null}`,
`{"v":1,"type":"call","id":"x","method":"x","params":null,"extra":true}`,
`{"v":1,"type":"event","method":"x","params":` + strings.Repeat("[", 34) + "0" + strings.Repeat("]", 34) + `}`,
}
for _, data := range invalid {
if _, err := decode([]byte(data), 1<<20); !errors.Is(err, ErrProtocol) {
t.Fatalf("accepted %s", data)
}
}
if _, err := decode([]byte(`{"v":1,"type":"event","method":"x","params":null}`), 4); err == nil {
t.Fatal("size ignored")
}
if err := p.receive(ws.Message{Type: ws.Binary, Data: []byte("x")}); !errors.Is(err, ErrProtocol) {
t.Fatal(err)
}
}
func TestPendingLimitAndDisconnect(t *testing.T) {
entered := make(chan struct{})
a, b := pair(t, Config{MaxPending: 1}, func(a, b *Peer) {
handle(t, b, "wait", func(ctx context.Context, _ json.RawMessage) (any, error) {
close(entered)
<-ctx.Done()
return nil, ctx.Err()
})
})
done := make(chan error, 1)
go func() { done <- a.Call(context.Background(), "wait", nil, nil) }()
<-entered
if err := a.Call(context.Background(), "wait", nil, nil); !errors.Is(err, ErrBusy) {
t.Fatal(err)
}
a.Stop()
b.Stop()
if err := <-done; !errors.Is(err, ErrClosed) {
t.Fatal(err)
}
if err := a.bind(&memoryTransport{context.Background(), b}); !errors.Is(err, ErrClosed) {
t.Fatal("peer rebound", err)
}
}
func TestSubscriptionsRestoreAndRemoval(t *testing.T) {
var mu sync.Mutex
var keys []string
setup := func(a, b *Peer) {
handle(t, b, "rpc.subscribe", func(_ context.Context, raw json.RawMessage) (any, error) {
var sub Subscription
if err := json.Unmarshal(raw, &sub); err != nil {
return nil, err
}
mu.Lock()
keys = append(keys, sub.Key)
mu.Unlock()
return nil, nil
})
handle(t, b, "rpc.unsubscribe", func(context.Context, json.RawMessage) (any, error) { return nil, nil })
}
a, _ := pair(t, Config{}, setup)
s, err := NewSubscriptions(SubscriptionConfig{MaxSubscriptions: 2})
if err != nil {
t.Fatal(err)
}
_ = s.Subscribe(context.Background(), nil, "b", map[string]int{"n": 1})
_ = s.Subscribe(context.Background(), nil, "a", nil)
if err = s.Subscribe(context.Background(), nil, "c", nil); !errors.Is(err, ErrBusy) {
t.Fatal(err)
}
if n, err := s.Restore(context.Background(), a); n != 2 || err != nil {
t.Fatal(n, err)
}
if err = s.Unsubscribe(context.Background(), a, "a"); err != nil {
t.Fatal(err)
}
next, _ := pair(t, Config{}, setup)
if n, err := s.Restore(context.Background(), next); n != 1 || err != nil {
t.Fatal(n, err)
}
mu.Lock()
defer mu.Unlock()
if strings.Join(keys, ",") != "a,b,b" {
t.Fatal(keys)
}
}
func TestRealTCPRPCAndShutdown(t *testing.T) {
opened := make(chan *Peer, 1)
server, err := ws.NewServer(ws.ServerConfig{Subprotocols: []string{Subprotocol}, RequireSubprotocol: true, Prepare: func(context.Context, *http.Request) (ws.Handlers, error) {
p, _ := New(Config{})
handle(t, p, "echo", func(_ context.Context, raw json.RawMessage) (any, error) { return raw, nil })
opened <- p
return p.Handlers(), nil
}})
if err != nil {
t.Fatal(err)
}
httpServer := httptest.NewServer(server.Handler(ws.Handlers{}))
defer httpServer.Close()
client, err := ws.NewClient(ws.ClientConfig{Subprotocols: []string{Subprotocol}, RequireSubprotocol: true, ProxyMode: "direct"})
if err != nil {
t.Fatal(err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = client.Shutdown(ctx)
_ = server.Shutdown(ctx)
}()
p, _ := New(Config{})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
session, err := client.Dial(ctx, "ws"+strings.TrimPrefix(httpServer.URL, "http"), p.Handlers())
if err != nil {
t.Fatal(err)
}
if err = p.WaitReady(ctx); err != nil {
t.Fatal(err)
}
remote := <-opened
var result map[string]int
if err = p.Call(ctx, "echo", map[string]int{"n": 42}, &result); err != nil || result["n"] != 42 {
t.Fatal(result, err)
}
if err = p.Shutdown(ctx); err != nil {
t.Fatal(err)
}
if err = session.Wait(ctx); err != nil {
t.Fatal(err)
}
if err = remote.Wait(ctx); err != nil {
t.Fatal(err)
}
}
func BenchmarkPeerCall(b *testing.B) {
a, _ := pair(b, Config{}, func(a, p *Peer) {
handle(b, p, "echo", func(_ context.Context, raw json.RawMessage) (any, error) { return raw, nil })
})
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var out int
if err := a.Call(context.Background(), "echo", 42, &out); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkProtocolDecode(b *testing.B) {
data := []byte(`{"v":1,"type":"call","id":"abc","method":"status.get","params":{"detail":true},"timeoutMs":1000}`)
b.ReportAllocs()
b.SetBytes(int64(len(data)))
for i := 0; i < b.N; i++ {
if _, err := decode(data, 1<<20); err != nil {
b.Fatal(err)
}
}
}
func FuzzProtocolDecode(f *testing.F) {
for _, s := range []string{`{"v":1,"type":"event","method":"x","params":null}`, `{"v":1,"type":"reply","id":"x","result":42}`, `null`, `{"v":1,"v":2}`} {
f.Add([]byte(s))
}
f.Fuzz(func(t *testing.T, data []byte) { _, _ = decode(data, 4096) })
}
type panickingResult struct{}
func (panickingResult) MarshalJSON() ([]byte, error) { panic("secret encoder state") }
func TestResultEncodingPanicAndOversize(t *testing.T) {
a, _ := pair(t, Config{MaxMessageBytes: 256}, func(a, b *Peer) {
handle(t, b, "panicResult", func(context.Context, json.RawMessage) (any, error) { return panickingResult{}, nil })
handle(t, b, "large", func(context.Context, json.RawMessage) (any, error) { return strings.Repeat("x", 1024), nil })
})
for _, method := range []string{"panicResult", "large"} {
err := a.Call(context.Background(), method, nil, nil)
var e *Error
if !errors.As(err, &e) {
t.Fatal(err)
}
if method == "panicResult" && e.Code != "INTERNAL" {
t.Fatal(e)
}
if method == "large" && e.Code != "RESULT_TOO_LARGE" {
t.Fatal(e)
}
}
}
func TestEventErrorObservation(t *testing.T) {
observed := make(chan error, 1)
a, _ := pair(t, Config{OnEventError: func(_ context.Context, err error) { observed <- err; panic("observer panic") }}, func(a, b *Peer) {
handle(t, b, "fail", func(context.Context, json.RawMessage) (any, error) { return nil, errors.New("local failure") })
})
if err := a.Notify(context.Background(), "fail", nil); err != nil {
t.Fatal(err)
}
select {
case err := <-observed:
if err.Error() != "local failure" {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("event error not observed")
}
}
func TestDuplicateIncomingIDRejected(t *testing.T) {
entered := make(chan struct{})
a, b := pair(t, Config{}, func(a, b *Peer) {
handle(t, b, "wait", func(ctx context.Context, _ json.RawMessage) (any, error) {
close(entered)
<-ctx.Done()
return nil, ctx.Err()
})
})
raw := ws.Message{Type: ws.Text, Data: []byte(`{"v":1,"type":"call","id":"same","method":"wait","params":null}`)}
if err := b.receive(raw); err != nil {
t.Fatal(err)
}
<-entered
if err := b.receive(raw); !errors.Is(err, ErrProtocol) {
t.Fatal("duplicate active ID accepted", err)
}
a.Stop()
b.Stop()
}

129
ws/rpc/protocol.go Normal file
View File

@@ -0,0 +1,129 @@
package rpc
import (
"bytes"
"encoding/json"
"io"
"strings"
"unicode/utf8"
)
type envelope struct {
Version int `json:"v"`
Type string `json:"type"`
ID string `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *Error `json:"error,omitempty"`
TimeoutMS int64 `json:"timeoutMs,omitempty"`
}
func validName(s string, max int) bool {
if s == "" || len(s) > max {
return false
}
for _, c := range s {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("._-/:", c)) {
return false
}
}
return true
}
func validError(e *Error) bool {
return e != nil && validName(e.Code, 64) && len(e.Message) <= 1024 && utf8.ValidString(e.Message)
}
func decode(data []byte, limit int) (envelope, error) {
var e envelope
if len(data) > limit || !utf8.Valid(data) {
return e, ErrProtocol
}
// Reject duplicate keys at every depth instead of accepting last-key-wins.
scan := json.NewDecoder(bytes.NewReader(data))
scan.UseNumber()
if err := uniqueValue(scan, 0); err != nil {
return e, ErrProtocol
}
if _, err := scan.Token(); err != io.EOF {
return e, ErrProtocol
}
d := json.NewDecoder(bytes.NewReader(data))
d.DisallowUnknownFields()
if err := d.Decode(&e); err != nil {
return e, ErrProtocol
}
if e.Version != 1 || e.TimeoutMS < 0 || e.TimeoutMS > int64((24*60*60)*1000) {
return e, ErrProtocol
}
switch e.Type {
case "call":
if !validName(e.ID, 64) || !validName(e.Method, 128) || len(e.Params) == 0 || len(e.Result) != 0 || e.Error != nil {
return e, ErrProtocol
}
case "event":
if e.ID != "" || !validName(e.Method, 128) || len(e.Params) == 0 || len(e.Result) != 0 || e.Error != nil || e.TimeoutMS != 0 {
return e, ErrProtocol
}
case "reply":
if !validName(e.ID, 64) || e.Method != "" || len(e.Params) != 0 || e.TimeoutMS != 0 || (len(e.Result) == 0) == (e.Error == nil) || (e.Error != nil && !validError(e.Error)) {
return e, ErrProtocol
}
case "cancel":
if !validName(e.ID, 64) || e.Method != "" || len(e.Params) != 0 || len(e.Result) != 0 || e.Error != nil || e.TimeoutMS != 0 {
return e, ErrProtocol
}
default:
return e, ErrProtocol
}
return e, nil
}
func uniqueValue(d *json.Decoder, depth int) error {
if depth > 32 {
return ErrProtocol
}
t, err := d.Token()
if err != nil {
return err
}
delim, ok := t.(json.Delim)
if !ok {
return nil
}
switch delim {
case '{':
seen := make(map[string]bool)
for d.More() {
k, err := d.Token()
if err != nil {
return err
}
key, ok := k.(string)
if !ok || seen[key] {
return ErrProtocol
}
seen[key] = true
if err := uniqueValue(d, depth+1); err != nil {
return err
}
}
t, err = d.Token()
if err != nil || t != json.Delim('}') {
return ErrProtocol
}
case '[':
for d.More() {
if err := uniqueValue(d, depth+1); err != nil {
return err
}
}
t, err = d.Token()
if err != nil || t != json.Delim(']') {
return ErrProtocol
}
default:
return ErrProtocol
}
return nil
}

159
ws/rpc/subscriptions.go Normal file
View File

@@ -0,0 +1,159 @@
package rpc
import (
"context"
"encoding/json"
"errors"
"sort"
"strings"
"unicode/utf8"
)
// Subscription is the generic request sent to the configured subscribe method.
// The server must authenticate and authorize Key and Params itself.
type Subscription struct {
Key string `json:"key"`
Params json.RawMessage `json:"params"`
}
type SubscriptionConfig struct {
MaxSubscriptions int `json:"maxSubscriptions"`
MaxParamsBytes int `json:"maxParamsBytes"`
SubscribeMethod string `json:"subscribeMethod"`
UnsubscribeMethod string `json:"unsubscribeMethod"`
}
// Subscriptions stores desired subscriptions, never received messages. Reuse it
// across sessions and explicitly Restore after each new Peer is bound. Server
// subscribe handlers must be idempotent for a key within a session; authorization
// is checked anew on every restore. No missed-event replay is implied.
type Subscriptions struct {
cfg SubscriptionConfig
gate chan struct{}
desired map[string]json.RawMessage
}
func validSubscriptionKey(key string) bool {
return key != "" && len(key) <= 256 && utf8.ValidString(key) && !strings.ContainsAny(key, "\r\n\x00")
}
func NewSubscriptions(cfg SubscriptionConfig) (*Subscriptions, error) {
if cfg.MaxSubscriptions < 0 || cfg.MaxSubscriptions > 65536 || cfg.MaxParamsBytes < 0 || cfg.MaxParamsBytes > 1<<20 {
return nil, errors.New("rpc: invalid subscription limits")
}
if cfg.MaxSubscriptions == 0 {
cfg.MaxSubscriptions = 256
}
if cfg.MaxParamsBytes == 0 {
cfg.MaxParamsBytes = 16 << 10
}
if cfg.SubscribeMethod == "" {
cfg.SubscribeMethod = "rpc.subscribe"
}
if cfg.UnsubscribeMethod == "" {
cfg.UnsubscribeMethod = "rpc.unsubscribe"
}
if !validName(cfg.SubscribeMethod, 128) || !validName(cfg.UnsubscribeMethod, 128) || cfg.SubscribeMethod == cfg.UnsubscribeMethod {
return nil, errors.New("rpc: distinct subscription methods required")
}
return &Subscriptions{cfg: cfg, gate: make(chan struct{}, 1), desired: make(map[string]json.RawMessage)}, nil
}
func (s *Subscriptions) lock(ctx context.Context) error {
select {
case s.gate <- struct{}{}:
if err := ctx.Err(); err != nil {
<-s.gate
return err
}
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Subscriptions) unlock() { <-s.gate }
// Subscribe records the desired state before attempting the call. On failure,
// it remains desired for a later Restore; the returned error must be inspected.
// A nil peer stores the subscription for the next explicit Restore.
func (s *Subscriptions) Subscribe(ctx context.Context, p *Peer, key string, params any) error {
if !validSubscriptionKey(key) {
return errors.New("rpc: invalid subscription key")
}
raw, err := json.Marshal(params)
if err != nil {
return err
}
if len(raw) > s.cfg.MaxParamsBytes {
return errors.New("rpc: subscription params exceed limit")
}
if err = s.lock(ctx); err != nil {
return err
}
defer s.unlock()
if _, exists := s.desired[key]; !exists && len(s.desired) >= s.cfg.MaxSubscriptions {
return ErrBusy
}
s.desired[key] = raw
if p == nil {
return nil
}
return p.Call(ctx, s.cfg.SubscribeMethod, Subscription{key, raw}, nil)
}
// Unsubscribe removes desired state even if the remote acknowledgement fails.
// The current connection may still receive events until remote removal or close.
func (s *Subscriptions) Unsubscribe(ctx context.Context, p *Peer, key string) error {
if !validSubscriptionKey(key) {
return errors.New("rpc: invalid subscription key")
}
if err := s.lock(ctx); err != nil {
return err
}
defer s.unlock()
delete(s.desired, key)
if p == nil {
return nil
}
return p.Call(ctx, s.cfg.UnsubscribeMethod, struct {
Key string `json:"key"`
}{key}, nil)
}
// Restore reauthorizes all desired subscriptions in deterministic order. It
// stops at the first error and reports how many were acknowledged. A caller can
// retry on the same peer only if its subscription handler is idempotent.
func (s *Subscriptions) Restore(ctx context.Context, p *Peer) (int, error) {
if p == nil {
return 0, ErrNotConnected
}
if err := s.lock(ctx); err != nil {
return 0, err
}
defer s.unlock()
keys := make([]string, 0, len(s.desired))
for key := range s.desired {
keys = append(keys, key)
}
sort.Strings(keys)
for i, key := range keys {
if err := p.Call(ctx, s.cfg.SubscribeMethod, Subscription{key, s.desired[key]}, nil); err != nil {
return i, err
}
}
return len(keys), nil
}
// Keys returns a sorted snapshot. In-flight restoration serializes desired
// changes; waiting for that operation always respects ctx.
func (s *Subscriptions) Keys(ctx context.Context) ([]string, error) {
if err := s.lock(ctx); err != nil {
return nil, err
}
defer s.unlock()
keys := make([]string, 0, len(s.desired))
for key := range s.desired {
keys = append(keys, key)
}
sort.Strings(keys)
return keys, nil
}