feat(ws): release generic WebSocket client and server v0.31.0-preview
This commit is contained in:
98
ws/rpc/README.md
Normal file
98
ws/rpc/README.md
Normal 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
67
ws/rpc/config.go
Normal 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
424
ws/rpc/peer.go
Normal 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
409
ws/rpc/peer_test.go
Normal 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
129
ws/rpc/protocol.go
Normal 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
159
ws/rpc/subscriptions.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user