diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f5a64b..064853e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# v0.31.0-preview — WebSocket(2026-10-29) + +- 新增 `ws` 通用 Client/Server/Session:WS/WSS、文本/二进制/JSON、TLS/mTLS、代理、握手认证/Origin/子协议、心跳、有限队列、重连、关闭握手和完整后台完成屏障。 +- 新增进程内 `ws/hub` 分组广播、可选 `ws/rpc` 对称调用/事件/取消与显式订阅恢复、`ws/observe` 安全日志与统计快照。消息不自动重放,不承诺离线或跨节点可靠投递。 +- App 新增 OnStop/OnDrain,复用 Runtime 的依赖保留规则;ws/omni 适配记录真实 101。API 生成入口支持可选的项目自有 RegisterCustomRoutes 方法。 +- 增加真实 TCP/TLS/代理、竞态、安全、模糊测试、性能基准和可运行示例。新增 Gorilla WebSocket 依赖;保持 Go 1.22 语言基线;CLI 默认框架版本同步为 v0.31.0-preview。 +- 兼容边界:nethttp HTTP/1.1;native、独立实验 HTTP/2 和 HTTPS 出站代理当前不支持。 +- 已知限制:原有 x/net HTTP/2 依赖仍有 4 项可达漏洞;浏览器实测未完成,详见 [验收记录](reports/websocket-validation-2026-10-29.md)。 + # v0.30.0-preview - 新增通用 `telemetry.HeaderPropagator` 与 `HeaderPropagationConfig`,配置化接入 Trace ID、Span ID、Request ID Header;不绑定任何网关名称。 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index cf16db6..fb6c481 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,4 +1,4 @@ -# 通用业务开发指南(v0.30.0-preview) +# 通用业务开发指南(v0.31.0-preview) 从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。 diff --git a/PROGRAM.md b/PROGRAM.md index ab37045..2a937e1 100644 --- a/PROGRAM.md +++ b/PROGRAM.md @@ -5,7 +5,7 @@ ## 创建与运行 ```sh -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview go-omni project new demo --module example.com/demo cd demo go mod tidy diff --git a/PROJECT.md b/PROJECT.md index 8665884..da6abe0 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1,5 +1,7 @@ # API 生成项目与文件维护边界 +v0.31 扩展:项目可以在 `svc.ServiceContext` 手写 `RegisterCustomRoutes(*omni.App) error`,新版生成的 `handler.NewApp` 在生成路由和内建端点注册后调用它,用于 WebSocket 等特殊协议。没有此方法时旧行为保持;重复路径继续由路由器拒绝。运行 `api generate` 更新入口即可,不手改受管文件。WS 组件在 svc.Init 登记 Stop/Drain,与其共享依赖使用同一生命周期,见 [通用 WS 接入](ws/README.md#goomni-接入与停机)。 + 通用 Go 工程使用独立的 `project new/generate/check`,见 [PROGRAM.md](PROGRAM.md)。本文说明 API 契约工程。 v0.28.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 diff --git a/README.md b/README.md index 4710e07..c9cfed2 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `project new` 生成配置、日志、依赖装配、生命周期和普通 Go 运行入口;需要 HTTP 接口时可选择 `.api` 工程生成,也可以在已有项目中按需引入独立模块。运行方式、业务类型和外部依赖由项目决定。 +**v0.31 改动:WebSocket**:新增通用 `ws.Client / Server / Session`,支持有界收发、心跳、重连、TLS、鉴权扩展和排空关闭;可选 `ws/hub` 分组广播、`ws/rpc` 双向调用与订阅恢复、`ws/observe` 日志指标。支持标准 nethttp HTTP/1.1 升级;native/实验 http2 暂不支持。见 [完整 API 与安全边界](ws/README.md)、[Go/浏览器示例](examples/websocket/README.md)、[RPC 示例](examples/websocketRPC/README.md)。 + **v0.29 改动**:通用签名读取新增 inline/attachment、中文显示文件名、可选 Content-Type 与实际到期时间;Local、S3/R2、OSS 使用同一接口,原下载接口兼容。本地支持 HEAD、Range、206/416,所有响应选项参与签名。见 [存储说明](storage/README.md#签名预览与下载)及[可运行 PDF 示例](examples/signedRead/README.md)。 **v0.30 改动**:telemetry 新增配置化 `HeaderPropagator`,兼容网关传入的 Trace/Span/Request Header,同时保留 W3C `traceparent`;入站父链路、日志关联和出站传播统一处理。框架不绑定任何网关名称。见[可运行示例](examples/telemetryHeaders/README.md)和[观测说明](ADVANCED.md#熔断与观测)。 @@ -14,9 +16,9 @@ GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `proj **v0.27 改动**:统一三种内置服务实现的默认/路由/硬请求体限额,配置冲突在启动前报错;停机以处理函数实际退出为资源清理前提,超时仍有请求时保留共享依赖。见 [HTTP 策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)和[可运行示例](examples/httpSafety/README.md)。 -**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.30.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。 +**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.31.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。 -**从这份 README 开始接入。** 当前版本 **`v0.30.0-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 +**从这份 README 开始接入。** 当前版本 **`v0.31.0-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 **v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。 @@ -69,6 +71,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可 | 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) | | 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) | | 出站 HTTP、熔断 | `httpc.New` 配置代理、连接池和阶段超时;JSON 支持单次成功状态限制/请求头/限额;`resilience.New` 隔离故障 | [HTTP 完整用法](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[熔断/观测](ADVANCED.md#熔断与观测) | +| WebSocket 双向通信 | `ws.NewClient/NewServer`;`ws/omni.Mount`;可选 Hub、RPC、订阅恢复和观测 | [功能、配置与停机](ws/README.md)、[浏览器与 Go 示例](examples/websocket/README.md)、[RPC 示例](examples/websocketRPC/README.md) | | 请求追踪、访问日志、恢复、限流、幂等、超时 | `app.Use(...)` 按需组合 middleware;跨副本选择 Redis 实现 | [模块说明与限制](MODULES.md#请求追踪与基础指标)、[分布式实现](ADVANCED.md#分布式限流与幂等) | | 本地后台任务与调度 | `task.New`、Submit、Every;有界队列、排空关闭 | [task 示例与生命周期](MODULES.md#后台执行task) | | 持久队列 | 业务使用 Publisher / Consumer;Redis Streams 提供至少一次交付 | [队列初始化/消费示例](ADVANCED.md#可靠队列) | @@ -81,7 +84,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可 ## 通用工程快速开始 ```sh -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview go-omni project new demo --module example.com/demo cd demo go mod tidy @@ -186,7 +189,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可 ```sh export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk" -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview export PATH="$(go env GOPATH)/bin:$PATH" go-omni --help ``` @@ -343,7 +346,7 @@ go-omni new --api catalog.api \ 在业务项目的 **go.mod 所在目录**执行: ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.30.0-preview +go get git.yuntuo.hk/k2safe/go-omni@v0.31.0-preview ``` 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。 @@ -674,7 +677,7 @@ go test ./... `main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。 -CLI 与业务 module 固定使用 `v0.30.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 +CLI 与业务 module 固定使用 `v0.31.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 ## 按需深入 diff --git a/RELEASE.md b/RELEASE.md index 45f04c7..26008c6 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,3 +1,21 @@ +# v0.31.0-preview — WebSocket + +新增可选通用 [WebSocket 模块](ws/README.md)。Client/Server 共用 Session,标准 WS/WSS 可与第三方互通;RPC 是另选的版本化协议。Hub 只管理进程内连接,订阅恢复仅恢复明确保存的订阅意图,不重放历史业务消息。 + +现有 HTTP 功能无需改配置。独立 App 使用 `ws/omni.Mount`,生成项目在 svc 登记 WS Stop/Drain,并可手写 `RegisterCustomRoutes(*omni.App) error`;重新生成入口后自动接入。资源只在 HTTP 和 WS 后台任务均退出后释放;未能确认处理器或后台任务退出时返回 `lifecycle.ErrResourcesRetained` 并保留依赖。 + +默认消息 1 MiB、每方向队列 64 条/4 MiB、压缩关闭、Origin 同源;重连显式启用。用户身份、组权限、业务错误和持久化由项目决定。服务端暂支持 nethttp HTTP/1.1 Upgrade,其他 Runner 明确拒绝。依赖固定 Gorilla WebSocket v1.5.3,工具链安全状态与实际测试结果见 [验收报告](reports/websocket-validation-2026-10-29.md)。 + +```sh +go get git.yuntuo.hk/k2safe/go-omni@v0.31.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview +go test -race ./ws/... ./app ./httpx +go run ./examples/websocket +go run ./examples/websocketRPC +``` + +**已知限制**:修补工具链下,核心 WS 包扫描未发现已知漏洞;包含 `ws/omni` 的完整接入仍受原有 x/net HTTP/2 的 4 项可达漏洞影响,框架整体安全发布门槛尚未通过。浏览器实测未完成。本次发布保留 Go 1.22 源码兼容,依赖安全升级需单独完成并回归,详情和证据见上述验收报告。 + # v0.30.0-preview 新增通用 `telemetry.HeaderPropagator`,让服务在不修改业务 Handler 的情况下接入使用自定义 Trace/Span/Request Header 的上游网关。配置 Header 名称后,入站建立远程父 Span,`logx.TraceID` / `RequestID`、访问日志和审计沿用上下文;出站 `telemetry.Transport` 同时注入 W3C 和兼容 Header。 diff --git a/app/app.go b/app/app.go index e2a652d..191fa26 100644 --- a/app/app.go +++ b/app/app.go @@ -31,6 +31,7 @@ type App struct { closers []func() error closeOnce sync.Once closeErr error + components *lifecycle.Runtime clientIPResolver *httpx.ClientIPResolver responseEncoder response.Encoder requests requestDrain @@ -170,7 +171,8 @@ func (a *App) Use(middleware ...httpx.Middleware) *App { } // OnShutdown registers a cleanup function owned by the application. Cleanup -// functions run in reverse registration order after all handlers have returned. +// functions run in reverse registration order after all handlers and registered +// background stop/drain hooks have finished. // Incomplete draining skips these callbacks and returns ErrResourcesRetained. // This keeps omni.New as the only public startup entry point while allowing // projects to attach databases, loggers and other resources to its lifecycle. @@ -181,6 +183,33 @@ func (a *App) OnShutdown(cleanup func() error) *App { return a } +// OnStop registers a background component's stop-admission hook. Each stop +// requires an OnDrain with the same name. Hooks run after HTTP handlers finish +// and before OnShutdown resources are released. Register before serving. +func (a *App) OnStop(name string, stop func(context.Context) error) *App { + a.componentRuntime().OnStop(name, stop) + return a +} + +// OnDrain registers proof that a background component, including its callbacks, +// has finished using shared resources. Incomplete draining retains resources. +// Generated services may instead register both hooks on their svc Runtime. +func (a *App) OnDrain(name string, drain func(context.Context) error) *App { + a.componentRuntime().OnDrain(name, drain) + return a +} + +func (a *App) componentRuntime() *lifecycle.Runtime { + if a.components == nil { + r, err := lifecycle.NewRuntimeWithConfig(context.Background(), a.config.Lifecycle) + if err != nil { + panic(err) + } + a.components = r + } + return a.components +} + // Close stops admission, cancels active requests and waits for their handlers // to return before releasing resources. It is idempotent. If cancellation grace // expires, ErrResourcesRetained is permanent and no cleanup callbacks run. @@ -192,7 +221,11 @@ func (a *App) Close() error { if timeout <= 0 { timeout = 5 * time.Second } - if err := a.requests.cancelAndWait(timeout); err != nil { + err := a.requests.cancelAndWait(timeout) + if a.components != nil { + err = a.components.Finish(err) + } + if err != nil { a.closeErr = err return } diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index 9fb0c38..a344d2b 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -20,7 +20,7 @@ API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、hand ```sh # 仓库读取权限与 PATH 配置见根目录 README -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview go-omni new --module example.com/hello ./hello # 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在 @@ -57,14 +57,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节 ## 版本与迁移 -CLI 与框架依赖固定使用 v0.30.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: +CLI 与框架依赖固定使用 v0.31.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: ```sh # 在 go-omni 源码仓库中执行 go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello ``` -当前模板与依赖以 v0.30.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 +当前模板与依赖以 v0.31.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 ## 数据库迁移命令 diff --git a/cmd/go-omni/apigen/customRoutes_test.go b/cmd/go-omni/apigen/customRoutes_test.go new file mode 100644 index 0000000..c55127f --- /dev/null +++ b/cmd/go-omni/apigen/customRoutes_test.go @@ -0,0 +1,185 @@ +package apigen + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +// Exercise the generated consumer, rather than matching a template substring: +// the optional hook must compile without changes to existing ServiceContext, +// survive regeneration, serve requests, and roll back App-owned resources. +func TestGeneratedCustomRoutesConsumer(t *testing.T) { + releaseCLI := os.Getenv("GO_OMNI_RELEASE_CLI") + releaseVersion := os.Getenv("GO_OMNI_RELEASE_VERSION") + if (releaseCLI == "") != (releaseVersion == "") { + t.Fatal("GO_OMNI_RELEASE_CLI and GO_OMNI_RELEASE_VERSION must be set together") + } + dir := filepath.Join(t.TempDir(), "customroutes") + api := filepath.Join(t.TempDir(), "service.api") + writeFile(t, api, "service customroutes {\n @handler ping\n get /ping\n}\n") + opts := Options{Dir: dir, API: api, Module: "example.com/customroutes", FrameworkDir: frameworkRoot(t)} + if releaseCLI != "" { + // The installed CLI must select its own published framework version. + // Never supply a source replacement in remote acceptance mode. + opts.FrameworkDir = "" + } + if err := generateManagedTestProject(t, opts); err != nil { + t.Fatal(err) + } + consumer := filepath.Join(dir, "custom_routes_test.go") + writeFile(t, consumer, customRoutesConsumerImports+` +func TestExistingServiceWithoutCustomRoutes(t *testing.T) { + deps:=newDependencies(t) + if _,ok:=any(deps).(interface{RegisterCustomRoutes(*omni.App) error});ok{t.Fatal("baseline unexpectedly implements extension")} + app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close() + response:=httptest.NewRecorder();app.ServeHTTP(response,httptest.NewRequest("GET","/ping",nil)) + if response.Code!=http.StatusNotImplemented{t.Fatalf("generated route: %d %s",response.Code,response.Body.String())} +} +`) + // First compile and run the old project shape before adding any method. + runConsumerGo(t, dir, "test", "-race", "-mod=mod", "-count=1", "./...") + + hookPath := filepath.Join(dir, "internal/svc/customRoutes.go") + writeFile(t, hookPath, customRoutesHook) + writeFile(t, consumer, customRoutesConsumerImports+customRoutesConsumerTests) + serviceContext := filepath.Join(dir, "internal/svc/serviceContext.go") + projectConfig := filepath.Join(dir, "internal/config/config.go") + handwritten := map[string]string{} + for _, path := range []string{hookPath, consumer, serviceContext, projectConfig} { + handwritten[path] = readFile(t, path) + } + opts.API = filepath.Join(dir, "api/service.api") + for _, mode := range []string{"dry-run", "generate", "check"} { + opts.DryRun = mode == "dry-run" + opts.Check = mode == "check" + if err := generateManagedTestProject(t, opts); err != nil { + t.Fatalf("%s: %v", mode, err) + } + for path, before := range handwritten { + if readFile(t, path) != before { + t.Fatalf("%s changed project-owned file %s", mode, path) + } + } + } + runConsumerGo(t, dir, "test", "-race", "-mod=mod", "-count=1", "./...") + runConsumerGo(t, dir, "build", "-mod=mod", ".") + if releaseCLI != "" { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + data, err := cmd.CombinedOutput() + if err != nil { + t.Fatal(err, string(data)) + } + var dependency struct { + Version, Sum string + Replace any + } + if err := json.Unmarshal(data, &dependency); err != nil { + t.Fatal(err) + } + if dependency.Version != releaseVersion || dependency.Replace != nil || dependency.Sum == "" { + t.Fatal("custom routes consumer is not using the fixed remote framework without replace") + } + t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum) + } +} + +const customRoutesConsumerImports = `package main +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "example.com/customroutes/internal/config" + "example.com/customroutes/internal/handler" + "example.com/customroutes/internal/svc" + omni "git.yuntuo.hk/k2safe/go-omni" + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +func newDependencies(t *testing.T)*svc.ServiceContext{ + t.Helper();cfg:=config.Default();cfg.Server.Log.Writer=io.Discard + logger,err:=logx.New(cfg.Server.Log);if err!=nil{t.Fatal(err)};t.Cleanup(func(){_ = logger.Close()}) + deps,err:=svc.New(context.Background(),logger,cfg);if err!=nil{t.Fatal(err)};t.Cleanup(func(){if err:=deps.Close();err!=nil{t.Error(err)}}) + return deps +} +` + +const customRoutesHook = `package svc + +import ( + "context" + "errors" + "net/http" + "sync/atomic" + + omni "git.yuntuo.hk/k2safe/go-omni" + "git.yuntuo.hk/k2safe/go-omni/httpx" +) + +var ErrCustomRegistration = errors.New("custom registration failed") +var ErrCustomCleanup = errors.New("custom cleanup failed") +var CustomCalls, CustomStopped, CustomDrained, CustomCleaned atomic.Int32 +var CustomFailure bool +var CustomApp *omni.App + +// This file belongs to the consuming project and must never be generated. +func(c *ServiceContext)RegisterCustomRoutes(app *omni.App)error{ + CustomCalls.Add(1);CustomApp=app + app.OnStop("project-custom",func(context.Context)error{CustomStopped.Add(1);return nil}) + app.OnDrain("project-custom",func(context.Context)error{CustomDrained.Add(1);return nil}) + fail:=CustomFailure + app.OnShutdown(func()error{CustomCleaned.Add(1);if fail{return ErrCustomCleanup};return nil}) + app.GET("/custom",httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){ + w.Header().Set("Content-Type","text/plain");_,_ = w.Write([]byte("project-owned route")) + }))) + if fail{return ErrCustomRegistration} + return nil +} + +func ResetCustomRoutes(){CustomCalls.Store(0);CustomStopped.Store(0);CustomDrained.Store(0);CustomCleaned.Store(0);CustomFailure=false;CustomApp=nil} + +func IsCustomFailure(err error)bool{return errors.Is(err,ErrCustomRegistration)&&errors.Is(err,ErrCustomCleanup)} +` + +const customRoutesConsumerTests = ` +func TestCustomRoutesServeAndClose(t *testing.T){ + svc.ResetCustomRoutes();deps:=newDependencies(t) + var extension interface{RegisterCustomRoutes(*omni.App) error}=deps + if extension==nil{t.Fatal("missing extension")} + app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close() + if svc.CustomCalls.Load()!=1{t.Fatal("hook called wrong number of times")} + server:=httptest.NewServer(app);defer server.Close() + for _,test:=range []struct{path string;status int;body string}{{"/custom",200,"project-owned route"},{"/ping",501,""},{"/health/ready",200,""}}{ + response,err:=http.Get(server.URL+test.path);if err!=nil{t.Fatal(err)} + body,err:=io.ReadAll(response.Body);response.Body.Close();if err!=nil{t.Fatal(err)} + if response.StatusCode!=test.status||(test.body!=""&&string(body)!=test.body){t.Fatalf("%s: %d %s",test.path,response.StatusCode,body)} + } + if svc.CustomCleaned.Load()!=0{t.Fatal("resource closed during successful construction")} + if err:=app.Close();err!=nil{t.Fatal(err)} + if svc.CustomStopped.Load()!=1||svc.CustomDrained.Load()!=1||svc.CustomCleaned.Load()!=1{t.Fatal("custom lifecycle did not finish")} +} + +func TestCustomRoutesFailureRollsBackOnlyAppResources(t *testing.T){ + svc.ResetCustomRoutes();svc.CustomFailure=true;defer func(){svc.CustomFailure=false}() + deps:=newDependencies(t);dependencyClosed:=false;deps.OnClose(func()error{dependencyClosed=true;return nil}) + app,err:=handler.NewApp(deps) + if app!=nil||!svc.IsCustomFailure(err){t.Fatalf("expected both hook and cleanup errors, got app=%v err=%v",app,err)} + if svc.CustomCalls.Load()!=1||svc.CustomStopped.Load()!=1||svc.CustomDrained.Load()!=1||svc.CustomCleaned.Load()!=1{t.Fatal("failed construction leaked App resources")} + if dependencyClosed{t.Fatal("NewApp closed caller-owned dependencies")} + response:=httptest.NewRecorder();svc.CustomApp.ServeHTTP(response,httptest.NewRequest("GET","/custom",nil)) + if response.Code!=http.StatusServiceUnavailable{t.Fatalf("failed App still accepts requests: %d",response.Code)} + _=svc.CustomApp.Close();if svc.CustomCleaned.Load()!=1{t.Fatal("rollback was not idempotent")} + if err:=deps.Close();err!=nil{t.Fatal(err)};if !dependencyClosed{t.Fatal("caller could not close dependencies")} +} +` diff --git a/cmd/go-omni/apigen/generate.go b/cmd/go-omni/apigen/generate.go index 5c092e8..99e52c7 100644 --- a/cmd/go-omni/apigen/generate.go +++ b/cmd/go-omni/apigen/generate.go @@ -23,7 +23,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" // Runtime-only releases need not raise the managed template compatibility floor. const minimumVersion = "v0.22.0-preview" -const currentVersion = "v0.30.0-preview" +const currentVersion = "v0.31.0-preview" const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n" type Options struct { diff --git a/cmd/go-omni/apigen/startup.go b/cmd/go-omni/apigen/startup.go index ce6546a..7857017 100644 --- a/cmd/go-omni/apigen/startup.go +++ b/cmd/go-omni/apigen/startup.go @@ -63,7 +63,14 @@ func NewApp(deps *svc.ServiceContext)(*omni.App,error){ for _,method:=range []string{"GET","HEAD","PUT"}{app.Handle(method,"/storage/object",httpx.WrapHandler(deps.FileHTTP))} ` } - source += "return app,nil\n}\n" + source += `// Optional project-owned extension; no generated file needs hand edits. + if extension,ok:=any(deps).(interface{RegisterCustomRoutes(*omni.App) error});ok{ + if err:=extension.RegisterCustomRoutes(app);err!=nil{return nil,errors.Join(err,app.Close())} + } + return app,nil +} +` + source = strings.Replace(source, "\"fmt\"", "\"fmt\";\"errors\"", 1) if o.Storage != "local" { source = strings.Replace(source, fmt.Sprintf("%q", frameworkModule+"/httpx"), "", 1) } diff --git a/cmd/go-omni/projectgen/generate.go b/cmd/go-omni/projectgen/generate.go index 28b232d..080e09e 100644 --- a/cmd/go-omni/projectgen/generate.go +++ b/cmd/go-omni/projectgen/generate.go @@ -25,7 +25,7 @@ import ( ) const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" -const currentVersion = "v0.30.0-preview" +const currentVersion = "v0.31.0-preview" const minimumVersion = "v0.28.0-preview" const manifestPath = ".goOmni/projectManifest.json" const lockPath = ".goOmni/project.lock" diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index 3dbe5eb..7a4d247 100644 --- a/cmd/go-omni/scaffold/version.go +++ b/cmd/go-omni/scaffold/version.go @@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" // Source builds can carry an unpublished VCS version (including +dirty). // Keep this fallback in sync with the next release; installed CLIs use their // downloaded module version, which also has a module checksum. -const defaultFrameworkVersion = "v0.30.0-preview" +const defaultFrameworkVersion = "v0.31.0-preview" // This target is not a claim that the release already exists. Source users // select a local framework checkout until the module templates are published. diff --git a/examples/managedService/.goOmni/apiManifest.json b/examples/managedService/.goOmni/apiManifest.json index c18966c..9bc1345 100644 --- a/examples/managedService/.goOmni/apiManifest.json +++ b/examples/managedService/.goOmni/apiManifest.json @@ -13,7 +13,7 @@ "generated": { "docs/openapi.json": "0370fd6bd6a387e2ef21f6b42b009ce66749846b8bea3d481009a34f3af039a5", "internal/config/loadGen.go": "d85e3123e4c96c95cb0886e60e4760230426efca4d2bbbfc981822da08b90d13", - "internal/handler/appGen.go": "f5251b4dc33e2312b0bdb71ead70d2171977d851265e124f640458d705a2cc9d", + "internal/handler/appGen.go": "66e54c8f60fc273b4dc856c9578390b15604d99ad2e581a1899cc4b7ebb70f3b", "internal/handler/demo/handlersGen.go": "3c76d85dbe89eb5127d9d134dc625203ecf9719dd11d174c1f335691b99e1375", "internal/handler/openapiGen.go": "f24cab3abbb8557a3fc4dd93fba23cc0b093f3e0c944d3ba1bd5b0eeff2d96f3", "internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399", diff --git a/examples/managedService/internal/handler/appGen.go b/examples/managedService/internal/handler/appGen.go index b2ded21..ee55e2b 100644 --- a/examples/managedService/internal/handler/appGen.go +++ b/examples/managedService/internal/handler/appGen.go @@ -2,6 +2,7 @@ package handler import ( + "errors" "fmt" omni "git.yuntuo.hk/k2safe/go-omni" "git.yuntuo.hk/k2safe/go-omni/contract" @@ -66,5 +67,11 @@ func NewApp(deps *svc.ServiceContext) (*omni.App, error) { } app.GET(cfg.Endpoints.Readiness.Path, deps.Health.ReadinessHandler(options...)) } + // Optional project-owned extension; no generated file needs hand edits. + if extension, ok := any(deps).(interface{ RegisterCustomRoutes(*omni.App) error }); ok { + if err := extension.RegisterCustomRoutes(app); err != nil { + return nil, errors.Join(err, app.Close()) + } + } return app, nil } diff --git a/examples/mixedWorkspace/README.md b/examples/mixedWorkspace/README.md index de2e26c..7d1a15c 100644 --- a/examples/mixedWorkspace/README.md +++ b/examples/mixedWorkspace/README.md @@ -1,11 +1,11 @@ # API 与通用工程混合工作区 -本例只定义生成工程的类型与目录,不包含业务模型、外部服务或特定调度方式。使用 v0.30.0-preview 或更新版本 CLI。 +本例只定义生成工程的类型与目录,不包含业务模型、外部服务或特定调度方式。使用 v0.31.0-preview 或更新版本 CLI。 下面从一个空目录创建两个独立 Go module;API 工程来自 API 模板,通用工程默认不监听端口。 ```sh -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview mkdir mixed-demo cd mixed-demo go-omni api new apps/interface --module example.com/interface diff --git a/examples/recursiveResponse/README.md b/examples/recursiveResponse/README.md index 75f86c5..82932c1 100644 --- a/examples/recursiveResponse/README.md +++ b/examples/recursiveResponse/README.md @@ -5,7 +5,7 @@ 在 go-omni 仓库根目录执行(新建独立临时目录): ```sh -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview example_dir="$(mktemp -d)/recursive" go-omni api generate --api examples/recursiveResponse/service.api --dir "$example_dir" --module example.com/recursive # 以下文件仅是本次新生成示例的占位实现,替换为示例自身维护的逻辑。 diff --git a/examples/websocket/README.md b/examples/websocket/README.md new file mode 100644 index 0000000..045fa9d --- /dev/null +++ b/examples/websocket/README.md @@ -0,0 +1,17 @@ +# WebSocket 通用示例 + +不依赖外部基础设施,默认在随机本机端口启动 GoOmni Server、Go Client 和 Hub,完成文本回显、分组广播后排空退出: + +```sh +go run ./examples/websocket +``` + +预期输出 `hello websocket`、`hello group`。浏览器标准 WebSocket 示例: + +```sh +go run ./examples/websocket -serve +``` + +打开 http://127.0.0.1:8088 的两个标签页,点击连接、发送、广播。Ctrl+C 触发 HTTP 排空、WS Stop/Drain、Hub 关闭。示例只监听 loopback,无鉴权;接入项目在升级前验证自己的身份和分组权限。浏览器使用同源策略;跨域部署通过明确的 `AllowedOrigins` 配置。 + +完整 API、限制与安全说明见 [ws](../../ws/README.md);双向调用与重连订阅见 [websocketRPC](../websocketRPC/README.md)。 diff --git a/examples/websocket/index.html b/examples/websocket/index.html new file mode 100644 index 0000000..d957893 --- /dev/null +++ b/examples/websocket/index.html @@ -0,0 +1,16 @@ + + +
打开两个标签页,连接后发送文本或广播。
+未连接
+ + diff --git a/examples/websocket/main.go b/examples/websocket/main.go new file mode 100644 index 0000000..d2dd4e5 --- /dev/null +++ b/examples/websocket/main.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + _ "embed" + "errors" + "flag" + "fmt" + "net/http/httptest" + "os" + "strings" + "time" + + omni "git.yuntuo.hk/k2safe/go-omni" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/ws" + "git.yuntuo.hk/k2safe/go-omni/ws/hub" + wsomni "git.yuntuo.hk/k2safe/go-omni/ws/omni" +) + +//go:embed index.html +var page []byte + +func main() { + serve := flag.Bool("serve", false, "serve the browser demo on 127.0.0.1:8088") + flag.Parse() + if err := run(*serve); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(serve bool) (runErr error) { + application := omni.New(omni.WithLogger(false)) + defer func() { runErr = errors.Join(runErr, application.Close()) }() + connections, err := hub.New(hub.Config{}) + if err != nil { + return err + } + application.OnShutdown(connections.Close) + server, err := ws.NewServer(ws.ServerConfig{}) + if err != nil { + return err + } + if err = wsomni.Mount(application, "/ws", server, ws.Handlers{ + OnOpen: func(_ context.Context, s *ws.Session) error { + if err := connections.Add(s); err != nil { + return err + } + return connections.Join(s.ID(), "demo") + }, + OnMessage: func(ctx context.Context, s *ws.Session, m ws.Message) error { + if m.Type == ws.Text && string(m.Data) == "broadcast" { + result, err := connections.SendGroup("demo", ws.Message{Type: ws.Text, Data: []byte("hello group")}) + if err != nil { + return err + } + if len(result.Errors) > 0 { + return s.SendJSON(ctx, map[string]int{"queued": result.Queued, "failed": len(result.Errors)}) + } + return nil + } + return s.Send(ctx, m) + }, + }); err != nil { + return err + } + application.GET("/", func(c *httpx.Context) error { + c.Raw().Header().Set("Content-Type", "text/html; charset=utf-8") + _, err := c.Raw().Write(page) + return err + }) + if serve { + fmt.Println("Open http://127.0.0.1:8088 in two browser tabs; Ctrl+C stops cleanly.") + return application.Run("127.0.0.1:8088") + } + httpServer := httptest.NewServer(application) + defer httpServer.Close() + client, err := ws.NewClient(ws.ClientConfig{ProxyMode: "direct"}) + if err != nil { + return err + } + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + runErr = errors.Join(runErr, client.Shutdown(ctx)) + }() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + replies := make(chan string, 2) + session, err := client.Dial(ctx, "ws"+strings.TrimPrefix(httpServer.URL, "http")+"/ws", ws.Handlers{OnMessage: func(_ context.Context, _ *ws.Session, m ws.Message) error { replies <- string(m.Data); return nil }}) + if err != nil { + return err + } + for _, message := range []string{"hello websocket", "broadcast"} { + if err = session.Send(ctx, ws.Message{Type: ws.Text, Data: []byte(message)}); err != nil { + return err + } + select { + case reply := <-replies: + fmt.Println(reply) + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} diff --git a/examples/websocketRPC/README.md b/examples/websocketRPC/README.md new file mode 100644 index 0000000..fedd4eb --- /dev/null +++ b/examples/websocketRPC/README.md @@ -0,0 +1,16 @@ +# 双向 RPC 与订阅恢复 + +从仓库根目录运行,无外部服务、数据库或凭证: + +```sh +go run ./examples/websocketRPC +go test -race ./examples/websocketRPC +``` + +示例在本地随机端口启动 HTTP/1.1 WebSocket 服务,客户端与服务端协商 `go-omni.rpc.v1`。每条连接创建独立 Peer:客户端调用 `double`,服务端处理 `identify` 时反向调用客户端的 `client.name`。随后客户端恢复 `updates` 订阅,服务端通过进程内 Hub 广播 RPC 事件;完成取消订阅和关闭后,用新连接再次执行恢复。 + +每代输出包含 `double=42`、反向调用返回值、`restored=1` 和对应事件序号。两次连接之间保留的是本地期望订阅,不是历史事件。示例显式创建第二条连接,方便验证每代 Peer 和订阅恢复的责任边界;自动网络重连使用 `ws.Client.Run` 与 `Prepare` 创建新 Peer,再从 `OnOpen` 外等待 `WaitReady` 并恢复订阅。 + +示例的订阅授权只允许公开的 `updates` 主题。生产项目在握手 `Prepare` 验证身份,每次订阅/恢复时检查具体主题权限。服务器注册订阅路由时通过连接 ID 加入 Hub;JSON 中的 key 不自动构成授权。没有离线补发、自动重试业务调用或持久化投递。 + +关闭 Client/Server 都使用独立、有界 context 并检查错误。RPC 的 OnClose 回调纳入实际业务任务完成屏障,示例的包装保留了该回调。完整协议、配置和退出语义见 [ws/rpc](../../ws/rpc/README.md)。 diff --git a/examples/websocketRPC/main.go b/examples/websocketRPC/main.go new file mode 100644 index 0000000..8155742 --- /dev/null +++ b/examples/websocketRPC/main.go @@ -0,0 +1,200 @@ +// websocketRPC demonstrates bidirectional calls and explicit subscription +// restoration over two successive real TCP connections, without external services. +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "time" + + "git.yuntuo.hk/k2safe/go-omni/ws" + "git.yuntuo.hk/k2safe/go-omni/ws/hub" + "git.yuntuo.hk/k2safe/go-omni/ws/rpc" +) + +func main() { + if err := run(); err != nil { + panic(err) + } +} + +func run() (runErr error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + groups, err := hub.New(hub.Config{}) + if err != nil { + return err + } + defer groups.Close() + server, err := ws.NewServer(ws.ServerConfig{ + Context: ctx, Subprotocols: []string{rpc.Subprotocol}, RequireSubprotocol: true, + Prepare: func(context.Context, *http.Request) (ws.Handlers, error) { + peer, err := rpc.New(rpc.Config{}) + if err != nil { + return ws.Handlers{}, err + } + var session *ws.Session + if err = peer.Handle("double", func(_ context.Context, raw json.RawMessage) (any, error) { + var n int + if json.Unmarshal(raw, &n) != nil { + return nil, &rpc.Error{Code: "INVALID_INPUT", Message: "integer required"} + } + return n * 2, nil + }); err != nil { + return ws.Handlers{}, err + } + if err = peer.Handle("identify", func(ctx context.Context, _ json.RawMessage) (any, error) { + var name string + err := peer.Call(ctx, "client.name", nil, &name) + return name, err + }); err != nil { + return ws.Handlers{}, err + } + if err = peer.Handle("rpc.subscribe", func(_ context.Context, raw json.RawMessage) (any, error) { + var sub rpc.Subscription + if json.Unmarshal(raw, &sub) != nil { + return nil, &rpc.Error{Code: "INVALID_INPUT", Message: "invalid subscription"} + } + // Demo policy. A real project authenticates in Prepare, then authorizes the + // requested topic against that verified identity on every subscription. + if sub.Key != "updates" { + return nil, &rpc.Error{Code: "DENIED", Message: "topic not allowed"} + } + return nil, groups.Join(session.ID(), sub.Key) + }); err != nil { + return ws.Handlers{}, err + } + if err = peer.Handle("rpc.unsubscribe", func(_ context.Context, raw json.RawMessage) (any, error) { + var sub struct { + Key string `json:"key"` + } + if json.Unmarshal(raw, &sub) != nil { + return nil, &rpc.Error{Code: "INVALID_INPUT", Message: "invalid subscription"} + } + groups.Leave(session.ID(), sub.Key) + return nil, nil + }); err != nil { + return ws.Handlers{}, err + } + handlers := peer.Handlers() + open := handlers.OnOpen + handlers.OnOpen = func(ctx context.Context, s *ws.Session) error { + session = s + if err := open(ctx, s); err != nil { + return err + } + return groups.Add(s) + } + closeHandler := handlers.OnClose + handlers.OnClose = func(s *ws.Session, err error) { closeHandler(s, err); groups.Remove(s.ID()) } + return handlers, nil + }, + }) + if err != nil { + return err + } + httpServer := httptest.NewServer(server.Handler(ws.Handlers{})) + defer httpServer.Close() + defer func() { + stop, done := context.WithTimeout(context.Background(), 2*time.Second) + defer done() + runErr = errors.Join(runErr, server.Shutdown(stop)) + }() + client, err := ws.NewClient(ws.ClientConfig{ProxyMode: "direct", Subprotocols: []string{rpc.Subprotocol}, RequireSubprotocol: true}) + if err != nil { + return err + } + defer func() { + stop, done := context.WithTimeout(context.Background(), 2*time.Second) + defer done() + runErr = errors.Join(runErr, client.Shutdown(stop)) + }() + subscriptions, err := rpc.NewSubscriptions(rpc.SubscriptionConfig{}) + if err != nil { + return err + } + if err = subscriptions.Subscribe(ctx, nil, "updates", nil); err != nil { + return err + } + endpoint := "ws" + strings.TrimPrefix(httpServer.URL, "http") + for generation := 1; generation <= 2; generation++ { + peer, err := rpc.New(rpc.Config{}) + if err != nil { + return err + } + events := make(chan int, 1) + if err = peer.Handle("client.name", func(context.Context, json.RawMessage) (any, error) { return "generic Go client", nil }); err != nil { + return err + } + if err = peer.Handle("updates.changed", func(ctx context.Context, raw json.RawMessage) (any, error) { + var v int + if err := json.Unmarshal(raw, &v); err != nil { + return nil, err + } + select { + case events <- v: + return nil, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }); 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 doubled int + if err = peer.Call(ctx, "double", 21, &doubled); err != nil { + return err + } + var name string + if err = peer.Call(ctx, "identify", nil, &name); err != nil { + return err + } + restored, err := subscriptions.Restore(ctx, peer) + if err != nil { + return err + } + // The documented envelope also permits native browser clients and Hub fanout. + payload, err := json.Marshal(map[string]any{"v": 1, "type": "event", "method": "updates.changed", "params": generation}) + if err != nil { + return err + } + sent, err := groups.SendGroup("updates", ws.Message{Type: ws.Text, Data: payload}) + if err != nil { + return err + } + if sent.Queued != 1 || len(sent.Errors) > 0 { + return fmt.Errorf("broadcast failed: queued %d of %d", sent.Queued, sent.Attempted) + } + select { + case received := <-events: + fmt.Printf("connection %d: double=%d, remote callback=%q, restored=%d, event=%d\n", generation, doubled, name, restored, received) + case <-ctx.Done(): + return ctx.Err() + } + if err = subscriptions.Unsubscribe(ctx, peer, "updates"); err != nil { + return err + } + // Keep desired state separately for the next physical connection. + if err = subscriptions.Subscribe(ctx, nil, "updates", nil); err != nil { + return err + } + if err = session.Close(1000, ""); err != nil { + return err + } + if err = session.Wait(ctx); err != nil { + return err + } + } + return nil +} diff --git a/examples/websocketRPC/main_test.go b/examples/websocketRPC/main_test.go new file mode 100644 index 0000000..969c15a --- /dev/null +++ b/examples/websocketRPC/main_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +func TestBidirectionalRPCAndRestore(t *testing.T) { + if err := run(); err != nil { + t.Fatal(err) + } +} diff --git a/go.mod b/go.mod index 0daf91d..c8143c5 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/go-sql-driver/mysql v1.9.3 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.18.1 + github.com/gorilla/websocket v1.5.3 github.com/minio/minio-go/v7 v7.0.83 github.com/redis/go-redis/v9 v9.17.3 go.opentelemetry.io/otel v1.31.0 diff --git a/go.sum b/go.sum index e758aca..679083f 100644 --- a/go.sum +++ b/go.sum @@ -62,6 +62,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/httpx/context.go b/httpx/context.go index 7fa6f8c..110330d 100644 --- a/httpx/context.go +++ b/httpx/context.go @@ -275,6 +275,14 @@ func (c *Context) Status() int { // Responded reports whether this handler has already written a response. func (c *Context) Responded() bool { return c.responded } +// MarkUpgraded records a successful WebSocket HTTP/1.1 upgrade whose 101 bytes +// were written directly to a hijacked connection. Call only after a successful +// upgrade; it does not write headers or upgrade a connection itself. +func (c *Context) MarkUpgraded() { + c.responded = true + c.status = http.StatusSwitchingProtocols +} + // WriteError writes a stable public error response. Internal causes are not // serialized. Responses committed through Context methods or Raw are preserved. func (c *Context) WriteError(err error) { diff --git a/lifecycle/README.md b/lifecycle/README.md index a64abd6..a792398 100644 --- a/lifecycle/README.md +++ b/lifecycle/README.md @@ -1,5 +1,11 @@ # lifecycle +## 独立 App 的后台组件 + +手写 `omni.New()` 应用也可以通过 `app.OnStop(name,hook)` / `app.OnDrain(name,hook)` 登记后台组件。HTTP Handler 结束后先运行 Stop、再 Drain,最后才执行逆序 OnShutdown。复用 `config.Lifecycle` 的阶段预算与 `ErrResourcesRetained` 规则,组件未退出时保留共享资源。没有登记后台钩子的旧 App 行为不变。 + +例如 `ws/omni.Mount` 自动登记 WS Server 的 Stop/Drain;生成项目则通常在 svc Runtime 上登记同一对钩子,并通过 `ws/omni.Handler` 注册路由。一个组件只选择一个所有者,不能把依赖释放登记到较早结束的另一个生命周期中。详见 [WS 接入](../ws/README.md#goomni-接入与停机)。 + ## 后台组件分阶段关闭 从 `v0.15.0-preview` 起,生成项目使用以下顺序:HTTP 停止接收并排空 → 全部 OnStop → 全部 OnDrain → 逆序 OnClose → 关闭日志。初始化、创建 App 或监听失败也经过后台收尾。Runtime 不导入 Orbit、数据库、ORM 或 Redis。 diff --git a/reports/websocket-bench-go1.25.txt b/reports/websocket-bench-go1.25.txt new file mode 100644 index 0000000..a06eed7 --- /dev/null +++ b/reports/websocket-bench-go1.25.txt @@ -0,0 +1,52 @@ +goos: darwin +goarch: arm64 +pkg: git.yuntuo.hk/k2safe/go-omni/ws +cpu: Apple M2 Max +BenchmarkClientRoundTrip-12 15706 47299 ns/op 0.19 MB/s 1520 B/op 13 allocs/op +BenchmarkClientRoundTrip-12 24013 26259 ns/op 0.34 MB/s 1520 B/op 13 allocs/op +BenchmarkClientRoundTrip-12 23534 25959 ns/op 0.35 MB/s 1520 B/op 13 allocs/op +BenchmarkServerRoundTrip/64-12 24288 24335 ns/op 5.26 MB/s 1568 B/op 13 allocs/op +BenchmarkServerRoundTrip/64-12 23325 23724 ns/op 5.40 MB/s 1568 B/op 13 allocs/op +BenchmarkServerRoundTrip/64-12 25240 23571 ns/op 5.43 MB/s 1568 B/op 13 allocs/op +BenchmarkServerRoundTrip/1024-12 23334 26435 ns/op 77.47 MB/s 7136 B/op 17 allocs/op +BenchmarkServerRoundTrip/1024-12 22939 28210 ns/op 72.60 MB/s 7136 B/op 17 allocs/op +BenchmarkServerRoundTrip/1024-12 23402 30573 ns/op 66.99 MB/s 7136 B/op 17 allocs/op +BenchmarkServerRoundTrip/65536-12 4402 140895 ns/op 930.28 MB/s 635956 B/op 45 allocs/op +BenchmarkServerRoundTrip/65536-12 4201 144080 ns/op 909.72 MB/s 635955 B/op 45 allocs/op +BenchmarkServerRoundTrip/65536-12 4046 145193 ns/op 902.74 MB/s 635953 B/op 45 allocs/op +BenchmarkSessionSend-12 110679 5370 ns/op 47.67 MB/s 1168 B/op 9 allocs/op +BenchmarkSessionSend-12 110362 5340 ns/op 47.94 MB/s 1168 B/op 9 allocs/op +BenchmarkSessionSend-12 113160 5339 ns/op 47.95 MB/s 1168 B/op 9 allocs/op +PASS +ok git.yuntuo.hk/k2safe/go-omni/ws 12.286s +goos: darwin +goarch: arm64 +pkg: git.yuntuo.hk/k2safe/go-omni/ws/hub +cpu: Apple M2 Max +BenchmarkHubBroadcast/1-12 3537676 152.5 ns/op 256 B/op 2 allocs/op +BenchmarkHubBroadcast/1-12 3975487 151.5 ns/op 256 B/op 2 allocs/op +BenchmarkHubBroadcast/1-12 3925134 154.0 ns/op 256 B/op 2 allocs/op +BenchmarkHubBroadcast/100-12 176622 3444 ns/op 3544 B/op 4 allocs/op +BenchmarkHubBroadcast/100-12 170431 3533 ns/op 3544 B/op 4 allocs/op +BenchmarkHubBroadcast/100-12 166556 3982 ns/op 3544 B/op 4 allocs/op +BenchmarkHubBroadcast/1000-12 8853 64870 ns/op 54656 B/op 6 allocs/op +BenchmarkHubBroadcast/1000-12 8582 65407 ns/op 54657 B/op 6 allocs/op +BenchmarkHubBroadcast/1000-12 8001 64712 ns/op 54657 B/op 6 allocs/op +PASS +ok git.yuntuo.hk/k2safe/go-omni/ws/hub 6.195s +PASS +ok git.yuntuo.hk/k2safe/go-omni/ws/observe 0.280s +PASS +ok git.yuntuo.hk/k2safe/go-omni/ws/omni 0.300s +goos: darwin +goarch: arm64 +pkg: git.yuntuo.hk/k2safe/go-omni/ws/rpc +cpu: Apple M2 Max +BenchmarkPeerCall-12 31053 17732 ns/op 8386 B/op 193 allocs/op +BenchmarkPeerCall-12 33787 17619 ns/op 8386 B/op 193 allocs/op +BenchmarkPeerCall-12 33901 17817 ns/op 8386 B/op 193 allocs/op +BenchmarkProtocolDecode-12 123168 4893 ns/op 19.62 MB/s 3656 B/op 106 allocs/op +BenchmarkProtocolDecode-12 122533 4824 ns/op 19.90 MB/s 3656 B/op 106 allocs/op +BenchmarkProtocolDecode-12 125842 4675 ns/op 20.54 MB/s 3656 B/op 106 allocs/op +PASS +ok git.yuntuo.hk/k2safe/go-omni/ws/rpc 4.531s diff --git a/reports/websocket-regression-go1.25.txt b/reports/websocket-regression-go1.25.txt new file mode 100644 index 0000000..48db07a --- /dev/null +++ b/reports/websocket-regression-go1.25.txt @@ -0,0 +1,86 @@ +ok git.yuntuo.hk/k2safe/go-omni 0.400s +ok git.yuntuo.hk/k2safe/go-omni/app 5.723s +ok git.yuntuo.hk/k2safe/go-omni/audit (cached) +ok git.yuntuo.hk/k2safe/go-omni/auth 1.045s +ok git.yuntuo.hk/k2safe/go-omni/cache (cached) +ok git.yuntuo.hk/k2safe/go-omni/cache/redis (cached) +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 3.720s +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 31.148s +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs (cached) +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen (cached) +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 2.786s +ok git.yuntuo.hk/k2safe/go-omni/config (cached) +ok git.yuntuo.hk/k2safe/go-omni/contract (cached) +ok git.yuntuo.hk/k2safe/go-omni/copyx (cached) +ok git.yuntuo.hk/k2safe/go-omni/cors (cached) +ok git.yuntuo.hk/k2safe/go-omni/database 2.063s +ok git.yuntuo.hk/k2safe/go-omni/database/gormx (cached) +ok git.yuntuo.hk/k2safe/go-omni/database/migrate (cached) +ok git.yuntuo.hk/k2safe/go-omni/database/mysql (cached) +ok git.yuntuo.hk/k2safe/go-omni/errors (cached) +? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/compatibility [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/compatibility/ids [no test files] +ok git.yuntuo.hk/k2safe/go-omni/examples/configuration (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/copying (cached) +? git.yuntuo.hk/k2safe/go-omni/examples/cors [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/developerTools [no test files] +ok git.yuntuo.hk/k2safe/go-omni/examples/httpClient (cached) +? git.yuntuo.hk/k2safe/go-omni/examples/httpSafety [no test files] +ok git.yuntuo.hk/k2safe/go-omni/examples/i18n 1.968s +ok git.yuntuo.hk/k2safe/go-omni/examples/modules 2.777s +ok git.yuntuo.hk/k2safe/go-omni/examples/platform 2.707s +ok git.yuntuo.hk/k2safe/go-omni/examples/reference 2.993s +? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files] +ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository (cached) +? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files] +ok git.yuntuo.hk/k2safe/go-omni/examples/signedRead (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/telemetryHeaders 2.822s +? git.yuntuo.hk/k2safe/go-omni/examples/transactions [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/websocket [no test files] +ok git.yuntuo.hk/k2safe/go-omni/examples/websocketRPC 2.839s +ok git.yuntuo.hk/k2safe/go-omni/health 3.382s +ok git.yuntuo.hk/k2safe/go-omni/httpc (cached) +ok git.yuntuo.hk/k2safe/go-omni/httpx 3.869s +ok git.yuntuo.hk/k2safe/go-omni/i18n (cached) +? git.yuntuo.hk/k2safe/go-omni/i18n/browser [no test files] +ok git.yuntuo.hk/k2safe/go-omni/i18n/errori18n (cached) +ok git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n (cached) +ok git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool (cached) +? git.yuntuo.hk/k2safe/go-omni/internal/configdiag [no test files] +? git.yuntuo.hk/k2safe/go-omni/internal/httplimits [no test files] +ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck (cached) +? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files] +? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files] +ok git.yuntuo.hk/k2safe/go-omni/lifecycle (cached) +ok git.yuntuo.hk/k2safe/go-omni/logx (cached) +ok git.yuntuo.hk/k2safe/go-omni/metrics (cached) +ok git.yuntuo.hk/k2safe/go-omni/middleware (cached) +ok git.yuntuo.hk/k2safe/go-omni/middleware/redis 2.257s +ok git.yuntuo.hk/k2safe/go-omni/openapi 2.425s +ok git.yuntuo.hk/k2safe/go-omni/pagination (cached) +? git.yuntuo.hk/k2safe/go-omni/queue [no test files] +ok git.yuntuo.hk/k2safe/go-omni/queue/redis (cached) +ok git.yuntuo.hk/k2safe/go-omni/resilience (cached) +ok git.yuntuo.hk/k2safe/go-omni/response (cached) +? git.yuntuo.hk/k2safe/go-omni/scripts/corsBrowser [no test files] +ok git.yuntuo.hk/k2safe/go-omni/storage (cached) +ok git.yuntuo.hk/k2safe/go-omni/storage/local (cached) +ok git.yuntuo.hk/k2safe/go-omni/storage/oss (cached) +ok git.yuntuo.hk/k2safe/go-omni/storage/s3 (cached) +? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files] +ok git.yuntuo.hk/k2safe/go-omni/task (cached) +ok git.yuntuo.hk/k2safe/go-omni/telemetry 2.595s +ok git.yuntuo.hk/k2safe/go-omni/transport 2.868s +ok git.yuntuo.hk/k2safe/go-omni/transport/http2 (cached) +? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files] +ok git.yuntuo.hk/k2safe/go-omni/transport/native 5.567s +ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 3.181s +? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files] +? git.yuntuo.hk/k2safe/go-omni/validate [no test files] +ok git.yuntuo.hk/k2safe/go-omni/ws 3.875s +ok git.yuntuo.hk/k2safe/go-omni/ws/hub 3.462s +ok git.yuntuo.hk/k2safe/go-omni/ws/observe 3.337s +ok git.yuntuo.hk/k2safe/go-omni/ws/omni 3.019s +ok git.yuntuo.hk/k2safe/go-omni/ws/rpc 3.064s diff --git a/reports/websocket-validation-2026-10-29.md b/reports/websocket-validation-2026-10-29.md new file mode 100644 index 0000000..2c1efa7 --- /dev/null +++ b/reports/websocket-validation-2026-10-29.md @@ -0,0 +1,85 @@ +# WebSocket 验收记录(2026-10-29) + +本次增加通用 WS Client/Server/Session、Hub、可选 RPC/订阅恢复、观测、App 生命周期与生成项目扩展。对应版本 `v0.31.0-preview`,本文记录源码验证;远程发布验收单独记录。 + +## 结论 + +- 功能、真实 TCP/TLS、并发竞态、静态检查、Go 1.22 兼容性和可运行 Go 示例通过。 +- 最终全库回归输出见 [websocket-regression-go1.25.txt](websocket-regression-go1.25.txt);Go 1.27.3 下 `go test ./ws/...` 也通过。生成示例只同步 appGen 与清单,生成漂移检查及消费项目编译通过;新增生成消费回归验证了自定义路由和失败清理。 +- Origin 模糊测试发现并修复无括号 IPv6 归一化歧义,失败样本保留在 `ws/testdata/fuzz/FuzzOrigin`;修复后 10 秒约 552,325 次执行通过。 +- 新增核心包在隔离 Go 1.27.3 工具链、govulncheck v1.9.0 下扫描未发现已知漏洞。 +- **包含 ws/omni 的完整接入安全扫描未通过**:仓库原有 `golang.org/x/net v0.33.0` 报告 4 项 HTTP/2 可达漏洞。不能将本次功能验收解释为整个框架已通过安全发布门槛。 +- 浏览器自动化访问 `http://127.0.0.1:8088` 被浏览器权限策略拒绝,因此浏览器 UI/浏览器协议实测未完成;不以 Go 客户端测试代替这一项。示例源文件可供手动运行,临时服务已停止。 + +## 功能与并发验证 + +使用 Apple M2 Max / darwin arm64,主测试工具链 Go 1.25.0。独立 Go 1.22.12 用于最低版本兼容检查,没有替换系统 Go。 + +执行过: + +```sh +go test ./... +go test -race -count=5 -timeout=120s ./ws/... ./examples/websocketRPC ./app ./httpx +go test -race -coverprofile=/tmp/go-omni-websocket-coverage.out ./ws/... +go vet ./ws/... ./app ./httpx ./examples/websocket ./examples/websocketRPC +GOTOOLCHAIN=go1.22.12 go test ./ws/... ./app ./httpx +go test ./ws -run '^$' -fuzz FuzzOrigin -fuzztime=10s -parallel=4 +go test ./ws/rpc -run '^$' -fuzz FuzzProtocolDecode -fuzztime=10s +go run ./examples/websocket +go run ./examples/websocketRPC +``` + +真实网络覆盖:回显、文本/二进制、128 个并行连接各 50 次往返、TLS/mTLS、HTTP CONNECT 代理认证、握手取消/超时、重连时刷新认证头、重试预算、快速重连与连接上限、双向嵌套 RPC、断线后两代 Session 的订阅恢复。 + +最终审查另修复了远端 `1001 going away` 被当成本地主动关闭、导致 Run 不重连的问题,并验证本地主动关闭仍停止重试;补充累计拨号/失败/重连指标和配置上界。修复后 `go test -race -count=3 ./ws/...` 通过。 + +安全与资源覆盖:同源/白名单/无 Origin/重复 Origin/null、拒绝错误子协议、认证拒绝与 panic、连接/握手速率限额、非法 UTF-8、压缩膨胀限额、发送数据拷贝、接收队列满、慢消费者、阻塞业务时仍处理 Ping/Pong、取消写入、断网、反复关闭、握手与停止并发、回调未退出时保留共享资源。 + +RPC 覆盖:双向请求、取消、迟到回复、重复在途 ID、未知字段、重复 JSON 键、嵌套深度、消息长度、pending/执行并发上限、公共与内部错误区分、panic 脱敏、订阅期望状态与恢复。RPC fuzz 10 秒约 71,268 次执行通过。 + +覆盖率快照: + +| 包 | 语句覆盖率 | +| --- | ---: | +| ws | 84.7% | +| ws/hub | 86.5% | +| ws/observe | 88.4% | +| ws/omni | 86.7% | +| ws/rpc | 80.7% | + +覆盖率只描述这组测试执行到的代码,不等同于正确性或安全证明。测试不包含生产长时间浸泡、跨地区网络容量、HTTP/2 extended CONNECT 或 Autobahn 全套认证。 + +## 性能基准 + +```sh +go test -run '^$' -bench . -benchmem -benchtime=500ms -count=3 ./ws/... +``` + +原始输出:[websocket-bench-go1.25.txt](websocket-bench-go1.25.txt)。环境为 Apple M2 Max、Go 1.25.0、GOMAXPROCS=12、loopback、无 TLS、压缩关闭。三次样本的范围如下;首轮客户端样本与其它验证有时间重叠,保留原始波动,不选取单个最好值作为承诺。 + +| 基准 | 时间/操作 | 分配 | +| --- | ---: | ---: | +| Client 回声往返(9 B) | 26.0–47.3 μs | 1520 B / 13 allocs | +| Server 回声往返(64 B) | 23.6–24.3 μs | 1568 B / 13 allocs | +| Server 回声往返(1 KiB) | 26.4–30.6 μs | 7136 B / 17 allocs | +| Server 回声往返(64 KiB) | 140.9–145.2 μs | 约 636 KB / 45 allocs | +| Session 发送(256 B) | 5.34–5.37 μs | 1168 B / 9 allocs | +| Hub 广播到 1000 个模拟连接 | 64.7–65.4 μs | 约 54.7 KB / 6 allocs | +| 内存传输 RPC Call | 17.6–17.8 μs | 8386 B / 193 allocs | +| 严格 RPC envelope 解析 | 4.68–4.89 μs | 3656 B / 106 allocs | + +网络往返基准包括双方处理及本机 socket 成本;64 KiB 场景的分配包含双方完整读入及安全发送拷贝,后续大消息优化需在保持消息所有权的前提下进行。Hub 基准仅测注册表和分发,RPC 内存基准不包含网络。这里没有从单连接 loopback 数字外推生产 QPS 或最大连接数。 + +内存预算需按连接数乘以消息/队列上限估算,并额外计入解码中消息、协议缓冲和业务持有的 payload。默认 4096 连接上限并不意味着任何机器都能承受所有连接同时用满双向 4 MiB 队列。部署时需根据机器容量设置 MaxConnections、消息与队列字节数、RPC 执行并发;压缩及 TLS 应另做容量测量。 + +## 漏洞扫描与剩余风险 + +扫描包含源码可达性分析,不是人工证明每条告警均可利用。没有隐藏、忽略或豁免扫描结果。 + +1. Go 1.25.0 + govulncheck v1.1.4 扫描 `./ws/...`:29 项标准库 symbol findings,以及 4 项 x/net symbol findings;Gorilla WebSocket 没有报告项。工具链版本是现有本机版本。扫描结果摘要保留在 [JSON](websocket-vuln-go1.25.json)。 +2. 隔离 Go 1.27.3 + govulncheck v1.9.0 扫描 `./ws/...`:标准库告警消除,剩余 4 项 x/net HTTP/2 findings,详见 [完整输出](websocket-vuln-patched-go.txt)。 +3. 同一修补工具链扫描 `./ws ./ws/hub ./ws/rpc ./ws/observe`:`No vulnerabilities found.`,见 [输出](websocket-vuln-core-patched-go.txt)。这不覆盖 ws/omni 引入的原有 App/HTTP2 依赖图。 + +剩余报告:GO-2026-6617(HPACK 并发)、GO-2026-6612(流控退款)、GO-2026-6611(重复窗口变更 CPU)、GO-2026-6603(Trailer 内存)。扫描器给出的修复版本为 `golang.org/x/net v0.60.0`;它的 [官方 go.mod](https://raw.githubusercontent.com/golang/net/v0.60.0/go.mod) 要求 Go 1.26,并升级 x/crypto、x/sys、x/text。此变更会改变框架当前公开的 Go 1.22 基线与多个既有适配器,未在 WS 功能提交中隐式实施。需要作为明确的框架安全升级进行全库与消费项目回归,生产发布前解决。 + +生产应使用受支持且已修补的 Go 工具链。Go 1.22 测试只验证源码兼容,不代表该旧工具链具备当前安全修复。对应标准库证书问题的官方说明见 [GO-2026-6809](https://pkg.go.dev/vuln/GO-2026-6809);HTTP/2 问题见 [GO-2026-6617](https://pkg.go.dev/vuln/GO-2026-6617)。 diff --git a/reports/websocket-vuln-core-patched-go.txt b/reports/websocket-vuln-core-patched-go.txt new file mode 100644 index 0000000..e758922 --- /dev/null +++ b/reports/websocket-vuln-core-patched-go.txt @@ -0,0 +1 @@ +No vulnerabilities found. diff --git a/reports/websocket-vuln-go1.25.json b/reports/websocket-vuln-go1.25.json new file mode 100644 index 0000000..3ed3149 --- /dev/null +++ b/reports/websocket-vuln-go1.25.json @@ -0,0 +1,839 @@ +{ + "command": "go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 -format=json ./ws/...", + "config": { + "protocol_version": "v1.0.0", + "scanner_name": "govulncheck", + "scanner_version": "v1.1.4", + "db": "https://vuln.go.dev", + "db_last_modified": "2026-10-27T21:16:18Z", + "go_version": "go1.25.0", + "scan_level": "symbol", + "scan_mode": "source" + }, + "findings": [ + { + "id": "GO-2025-3487", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.35.0", + "symbolReachable": false, + "summary": "Potential denial of service in golang.org/x/crypto" + }, + { + "id": "GO-2025-4116", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.43.0", + "symbolReachable": false, + "summary": "Potential denial of service in golang.org/x/crypto/ssh/agent" + }, + { + "id": "GO-2025-4134", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.45.0", + "symbolReachable": false, + "summary": "Unbounded memory consumption in golang.org/x/crypto/ssh" + }, + { + "id": "GO-2025-4135", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.45.0", + "symbolReachable": false, + "summary": "Malformed constraint may cause denial of service in golang.org/x/crypto/ssh/agent" + }, + { + "id": "GO-2026-5005", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.52.0", + "symbolReachable": false, + "summary": "Invoking key constraints not enforced in golang.org/x/crypto/ssh/agent" + }, + { + "id": "GO-2026-5006", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.52.0", + "symbolReachable": false, + "summary": "Invoking agent constraints dropped when forwarding keys in golang.org/x/crypto/ssh/agent" + }, + { + "id": "GO-2026-5013", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.52.0", + "symbolReachable": false, + "summary": "Invoking byte arithmetic causes underflow and panic in golang.org/x/crypto/ssh" + }, + { + "id": "GO-2026-5014", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.52.0", + "symbolReachable": false, + "summary": "Invoking bypass of certificate restrictions in golang.org/x/crypto/ssh" + }, + { + "id": "GO-2026-5015", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.52.0", + "symbolReachable": false, + "summary": "Invoking server panic during CheckHostKey/Authenticate in golang.org/x/crypto/ssh" + }, + { + "id": "GO-2026-5016", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.52.0", + "symbolReachable": false, + "summary": "Invoking memory leak when rejecting channels can lead to DoS in golang.org/x/crypto/ssh" + }, + { + "id": "GO-2026-5017", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.52.0", + "symbolReachable": false, + "summary": "Invoking client can cause server deadlock on unexpected responses in golang.org/x/crypto/ssh" + }, + { + "id": "GO-2026-5018", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.52.0", + "symbolReachable": false, + "summary": "Invoking pathological RSA/DSA parameters may cause DoS in golang.org/x/crypto/ssh" + }, + { + "id": "GO-2026-5019", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.52.0", + "symbolReachable": false, + "summary": "Invoking bypass of FIDO/U2F security keys physical interaction in golang.org/x/crypto/ssh" + }, + { + "id": "GO-2026-5020", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.52.0", + "symbolReachable": false, + "summary": "Invoking infinite loop on large channel writes in golang.org/x/crypto/ssh" + }, + { + "id": "GO-2026-5021", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.52.0", + "symbolReachable": false, + "summary": "Invoking auth bypass via unenforced @revoked status in golang.org/x/crypto/ssh/knownhosts" + }, + { + "id": "GO-2026-5023", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.52.0", + "symbolReachable": false, + "summary": "Invoking VerifiedPublicKeyCallback permissions skip enforcement in golang.org/x/crypto/ssh" + }, + { + "id": "GO-2026-5033", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.52.0", + "symbolReachable": false, + "summary": "Invoking pathological inputs can lead to client panic in golang.org/x/crypto/ssh/agent" + }, + { + "id": "GO-2026-5932", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": null, + "symbolReachable": false, + "summary": "The golang.org/x/crypto/openpgp package is unmaintained, unsafe by design, and has known security issues" + }, + { + "id": "GO-2026-6303", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.55.0", + "symbolReachable": false, + "summary": "Source-address critical option not enforced for non-public-key auth callbacks in golang.org/x/crypto/ssh" + }, + { + "id": "GO-2026-6354", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.56.0", + "symbolReachable": false, + "summary": "Prevent DoS on deadlocked undecided channel in golang.org/x/crypto/ssh" + }, + { + "id": "GO-2026-6355", + "module": "golang.org/x/crypto", + "found": "v0.31.0", + "fixed": "v0.56.0", + "symbolReachable": false, + "summary": "Prevent DoS on deadlocked established channel in golang.org/x/crypto/ssh" + }, + { + "id": "GO-2025-3955", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.1", + "symbolReachable": false, + "summary": "CrossOriginProtection insecure bypass patterns not limited to exact matches in net/http" + }, + { + "id": "GO-2025-4006", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.2", + "symbolReachable": false, + "summary": "Excessive CPU consumption in ParseAddress in net/mail" + }, + { + "id": "GO-2025-4007", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.3", + "symbolReachable": true, + "summary": "Quadratic complexity when checking name constraints in crypto/x509" + }, + { + "id": "GO-2025-4008", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.2", + "symbolReachable": true, + "summary": "ALPN negotiation error contains attacker controlled information in crypto/tls" + }, + { + "id": "GO-2025-4009", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.2", + "symbolReachable": false, + "summary": "Quadratic complexity when parsing some invalid inputs in encoding/pem" + }, + { + "id": "GO-2025-4010", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.2", + "symbolReachable": true, + "summary": "Insufficient validation of bracketed IPv6 hostnames in net/url" + }, + { + "id": "GO-2025-4011", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.2", + "symbolReachable": true, + "summary": "Parsing DER payload can cause memory exhaustion in encoding/asn1" + }, + { + "id": "GO-2025-4012", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.2", + "symbolReachable": true, + "summary": "Lack of limit when parsing cookies can cause memory exhaustion in net/http" + }, + { + "id": "GO-2025-4013", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.2", + "symbolReachable": true, + "summary": "Panic when validating certificates with DSA public keys in crypto/x509" + }, + { + "id": "GO-2025-4014", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.2", + "symbolReachable": false, + "summary": "Unbounded allocation when parsing GNU sparse map in archive/tar" + }, + { + "id": "GO-2025-4015", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.2", + "symbolReachable": false, + "summary": "Excessive CPU consumption in Reader.ReadResponse in net/textproto" + }, + { + "id": "GO-2025-4155", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.5", + "symbolReachable": true, + "summary": "Excessive resource consumption when printing error string for host certificate validation in crypto/x509" + }, + { + "id": "GO-2025-4175", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.5", + "symbolReachable": true, + "summary": "Improper application of excluded DNS name constraints when verifying wildcard names in crypto/x509" + }, + { + "id": "GO-2026-4337", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.7", + "symbolReachable": true, + "summary": "Unexpected session resumption in crypto/tls" + }, + { + "id": "GO-2026-4340", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.6", + "symbolReachable": true, + "summary": "Handshake messages may be processed at the incorrect encryption level in crypto/tls" + }, + { + "id": "GO-2026-4341", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.6", + "symbolReachable": false, + "summary": "Memory exhaustion in query parameter parsing in net/url" + }, + { + "id": "GO-2026-4342", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.6", + "symbolReachable": false, + "summary": "Excessive CPU consumption when building archive index in archive/zip" + }, + { + "id": "GO-2026-4601", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.8", + "symbolReachable": true, + "summary": "Incorrect parsing of IPv6 host literals in net/url" + }, + { + "id": "GO-2026-4602", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.8", + "symbolReachable": true, + "summary": "FileInfo can escape from a Root in os" + }, + { + "id": "GO-2026-4603", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.8", + "symbolReachable": false, + "summary": "URLs in meta content attribute actions are not escaped in html/template" + }, + { + "id": "GO-2026-4864", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.9", + "symbolReachable": false, + "summary": "TOCTOU permits root escape on Linux via Root.Chmod in os in internal/syscall/unix" + }, + { + "id": "GO-2026-4865", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.9", + "symbolReachable": false, + "summary": "JsBraceDepth Context Tracking Bugs (XSS) in html/template" + }, + { + "id": "GO-2026-4869", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.9", + "symbolReachable": false, + "summary": "Unbounded allocation for old GNU sparse in archive/tar" + }, + { + "id": "GO-2026-4870", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.9", + "symbolReachable": true, + "summary": "Unauthenticated TLS 1.3 KeyUpdate record can cause persistent connection retention and DoS in crypto/tls" + }, + { + "id": "GO-2026-4918", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.10", + "symbolReachable": false, + "summary": "Infinite loop in HTTP/2 transport when given bad SETTINGS_MAX_FRAME_SIZE in net/http/internal/http2 in golang.org/x/net" + }, + { + "id": "GO-2026-4946", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.9", + "symbolReachable": true, + "summary": "Inefficient policy validation in crypto/x509" + }, + { + "id": "GO-2026-4947", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.9", + "symbolReachable": true, + "summary": "Unexpected work during chain building in crypto/x509" + }, + { + "id": "GO-2026-4970", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.12", + "symbolReachable": false, + "summary": "Root escape via symlink plus trailing slash in os" + }, + { + "id": "GO-2026-4971", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.10", + "symbolReachable": true, + "summary": "Panic in Dial and LookupPort when handling NUL byte on Windows in net" + }, + { + "id": "GO-2026-4976", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.10", + "symbolReachable": false, + "summary": "ReverseProxy forwards queries with more than urlmaxqueryparams parameters in net/http/httputil" + }, + { + "id": "GO-2026-4977", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.10", + "symbolReachable": false, + "summary": "Quadratic string concatenation in consumePhrase in net/mail" + }, + { + "id": "GO-2026-4980", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.10", + "symbolReachable": false, + "summary": "Escaper bypass leads to XSS in html/template" + }, + { + "id": "GO-2026-4981", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.10", + "symbolReachable": false, + "summary": "Crash when handling long CNAME response in net" + }, + { + "id": "GO-2026-4982", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.10", + "symbolReachable": false, + "summary": "Bypass of meta content URL escaping causes XSS in html/template" + }, + { + "id": "GO-2026-4986", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.10", + "symbolReachable": false, + "summary": "Quadratic string concatentation in consumeComment in net/mail" + }, + { + "id": "GO-2026-5026", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.13", + "symbolReachable": false, + "summary": "Invoking failure to reject ASCII-only Punycode-encoded labels in golang.org/x/net/idna" + }, + { + "id": "GO-2026-5037", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.11", + "symbolReachable": true, + "summary": "Inefficient candidate hostname parsing in crypto/x509" + }, + { + "id": "GO-2026-5038", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.11", + "symbolReachable": false, + "summary": "Quadratic complexity in WordDecoder.DecodeHeader in mime" + }, + { + "id": "GO-2026-5039", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.11", + "symbolReachable": true, + "summary": "Arbitrary inputs are included in errors without any escaping in net/textproto" + }, + { + "id": "GO-2026-5856", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.12", + "symbolReachable": true, + "summary": "Invoking Encrypted Client Hello privacy leak in crypto/tls" + }, + { + "id": "GO-2026-5972", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.13", + "symbolReachable": true, + "summary": "Enforce maximum recursion depth in encoding/asn1" + }, + { + "id": "GO-2026-6088", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.13", + "symbolReachable": false, + "summary": "Add recursion depth guard during decode in encoding/xml" + }, + { + "id": "GO-2026-6089", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.13", + "symbolReachable": false, + "summary": "Apply ReadHeaderTimeout when doing unencrypted HTTP/2 check in net/http" + }, + { + "id": "GO-2026-6090", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.13", + "symbolReachable": true, + "summary": "Limit handshake messages we are willing to accept post-handshake in crypto/tls" + }, + { + "id": "GO-2026-6091", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.13", + "symbolReachable": false, + "summary": "Fix Javascript regexp context tracking in html/template" + }, + { + "id": "GO-2026-6218", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.25.13", + "symbolReachable": false, + "summary": "Avoid quadratic complexity in resolvePath in net/url" + }, + { + "id": "GO-2026-6599", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.26.9", + "symbolReachable": false, + "summary": "Reset context tracking on consecutive template expressions in html/template" + }, + { + "id": "GO-2026-6600", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.26.9", + "symbolReachable": false, + "summary": "Recognize yield as regexp preceder keyword in html/template" + }, + { + "id": "GO-2026-6603", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.26.9", + "symbolReachable": true, + "summary": "HTTP/2 server memory exhaustion due to Trailer headers in net/http" + }, + { + "id": "GO-2026-6604", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.26.9", + "symbolReachable": false, + "summary": "Root.Mkdir(All) can follow junctions out of the root on Windows in os" + }, + { + "id": "GO-2026-6605", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.26.9", + "symbolReachable": false, + "summary": "HTTP/1 client connection desynchronization after CONNECT rejection in net/http" + }, + { + "id": "GO-2026-6607", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.26.9", + "symbolReachable": true, + "summary": "Reject malformed ECH outer extension references in crypto/tls" + }, + { + "id": "GO-2026-6608", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.26.9", + "symbolReachable": true, + "summary": "Memory limit bypass when parsing MIME headers in net/textproto, mime/multipart" + }, + { + "id": "GO-2026-6609", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.26.9", + "symbolReachable": false, + "summary": "Lack of limit on size of parsed Range headers in net/http" + }, + { + "id": "GO-2026-6610", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.26.9", + "symbolReachable": false, + "summary": "HTTP/2 transport accepts malformed framing-related headers in net/http" + }, + { + "id": "GO-2026-6611", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.26.9", + "symbolReachable": true, + "summary": "Excessive CPU consumption from repeated initial window changes in net/http" + }, + { + "id": "GO-2026-6612", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.26.9", + "symbolReachable": true, + "summary": "Double flow control refund on HTTP/2 server streams in net/http" + }, + { + "id": "GO-2026-6613", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.26.9", + "symbolReachable": true, + "summary": "HTTP/1 server connection desynchronization after 2xx CONNECT response in net/http" + }, + { + "id": "GO-2026-6617", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.26.9", + "symbolReachable": true, + "summary": "HTTP/2 server crash due to HPACK encoder race in net/http" + }, + { + "id": "GO-2026-6809", + "module": "stdlib", + "found": "v1.25.0", + "fixed": "v1.26.10", + "symbolReachable": true, + "summary": "Distrust compromised intermediate certificates in crypto/x509" + }, + { + "id": "GO-2025-3503", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.36.0", + "symbolReachable": false, + "summary": "HTTP Proxy bypass using IPv6 Zone IDs in golang.org/x/net" + }, + { + "id": "GO-2025-3595", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.38.0", + "symbolReachable": false, + "summary": "Incorrect Neutralization of Input During Web Page Generation in x/net in golang.org/x/net" + }, + { + "id": "GO-2026-4440", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.45.0", + "symbolReachable": false, + "summary": "Quadratic parsing complexity in golang.org/x/net/html" + }, + { + "id": "GO-2026-4441", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.45.0", + "symbolReachable": false, + "summary": "Infinite parsing loop in golang.org/x/net" + }, + { + "id": "GO-2026-4918", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.53.0", + "symbolReachable": false, + "summary": "Infinite loop in HTTP/2 transport when given bad SETTINGS_MAX_FRAME_SIZE in net/http/internal/http2 in golang.org/x/net" + }, + { + "id": "GO-2026-5025", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.55.0", + "symbolReachable": false, + "summary": "Invoking incorrect handling of namespaced elements in foreign content in golang.org/x/net/html" + }, + { + "id": "GO-2026-5026", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.55.0", + "symbolReachable": false, + "summary": "Invoking failure to reject ASCII-only Punycode-encoded labels in golang.org/x/net/idna" + }, + { + "id": "GO-2026-5027", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.55.0", + "symbolReachable": false, + "summary": "Invoking incorrect handling of HTML elements in foreign content in golang.org/x/net/html" + }, + { + "id": "GO-2026-5028", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.55.0", + "symbolReachable": false, + "summary": "Invoking denial of service when parsing arbitrary HTML in golang.org/x/net/html" + }, + { + "id": "GO-2026-5029", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.55.0", + "symbolReachable": false, + "summary": "Invoking incorrect handling of character references in DOCTYPE nodes in golang.org/x/net/html" + }, + { + "id": "GO-2026-5030", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.55.0", + "symbolReachable": false, + "summary": "Invoking duplicate attributes can cause XSS in golang.org/x/net/html" + }, + { + "id": "GO-2026-5942", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.56.0", + "symbolReachable": false, + "summary": "Parsing an invalid SVCB or HTTPS RR can panic in golang.org/x/net/dns/dnsmessage" + }, + { + "id": "GO-2026-6603", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.60.0", + "symbolReachable": true, + "summary": "HTTP/2 server memory exhaustion due to Trailer headers in net/http" + }, + { + "id": "GO-2026-6610", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.60.0", + "symbolReachable": false, + "summary": "HTTP/2 transport accepts malformed framing-related headers in net/http" + }, + { + "id": "GO-2026-6611", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.60.0", + "symbolReachable": true, + "summary": "Excessive CPU consumption from repeated initial window changes in net/http" + }, + { + "id": "GO-2026-6612", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.60.0", + "symbolReachable": true, + "summary": "Double flow control refund on HTTP/2 server streams in net/http" + }, + { + "id": "GO-2026-6617", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.60.0", + "symbolReachable": true, + "summary": "HTTP/2 server crash due to HPACK encoder race in net/http" + }, + { + "id": "GO-2026-6860", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.62.0", + "symbolReachable": false, + "summary": "Avoid quadratic complexity when coalescing text nodes in html in golang.org/x/net" + }, + { + "id": "GO-2026-6866", + "module": "golang.org/x/net", + "found": "v0.33.0", + "fixed": "v0.62.0", + "symbolReachable": false, + "summary": "Check all ancestor namespace transitions when rendering raw text in html in golang.org/x/net" + }, + { + "id": "GO-2026-5024", + "module": "golang.org/x/sys", + "found": "v0.28.0", + "fixed": "v0.44.0", + "symbolReachable": false, + "summary": "Invoking integer overflow in NewNTUnicodeString in golang.org/x/sys/windows" + }, + { + "id": "GO-2026-5970", + "module": "golang.org/x/text", + "found": "v0.21.0", + "fixed": "v0.39.0", + "symbolReachable": false, + "summary": "Infinite loop on invalid input in golang.org/x/text" + }, + { + "id": "GO-2026-6629", + "module": "golang.org/x/text", + "found": "v0.21.0", + "fixed": "v0.41.0", + "symbolReachable": false, + "summary": "Panic parsing crafted input in x/text/secure/precis in golang.org/x/text" + } + ] +} diff --git a/reports/websocket-vuln-patched-go.txt b/reports/websocket-vuln-patched-go.txt new file mode 100644 index 0000000..55c0165 --- /dev/null +++ b/reports/websocket-vuln-patched-go.txt @@ -0,0 +1,83 @@ +=== Symbol Results === + +Vulnerability #1: GO-2026-6617 + HTTP/2 server crash due to HPACK encoder race in net/http + More info: https://pkg.go.dev/vuln/GO-2026-6617 + Module: golang.org/x/net + Found in: golang.org/x/net@v0.33.0 + Fixed in: golang.org/x/net@v0.60.0 + Example traces found: + #1: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.ErrCode.String + #2: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.FrameType.String + #3: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.Setting.String + #4: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.SettingID.String + #5: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.bufferedWriterTimeoutWriter.Write + #6: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.chunkWriter.Write + #7: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.duplicatePseudoHeaderError.Error + #8: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldNameError.Error + #9: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldValueError.Error + #10: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.pseudoHeaderError.Error + #11: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.writeData.String + +Vulnerability #2: GO-2026-6612 + Double flow control refund on HTTP/2 server streams in net/http + More info: https://pkg.go.dev/vuln/GO-2026-6612 + Module: golang.org/x/net + Found in: golang.org/x/net@v0.33.0 + Fixed in: golang.org/x/net@v0.60.0 + Example traces found: + #1: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.ErrCode.String + #2: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.FrameType.String + #3: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.Setting.String + #4: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.SettingID.String + #5: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.bufferedWriterTimeoutWriter.Write + #6: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.chunkWriter.Write + #7: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.duplicatePseudoHeaderError.Error + #8: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldNameError.Error + #9: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldValueError.Error + #10: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.pseudoHeaderError.Error + #11: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.writeData.String + +Vulnerability #3: GO-2026-6611 + Excessive CPU consumption from repeated initial window changes in net/http + More info: https://pkg.go.dev/vuln/GO-2026-6611 + Module: golang.org/x/net + Found in: golang.org/x/net@v0.33.0 + Fixed in: golang.org/x/net@v0.60.0 + Example traces found: + #1: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.ErrCode.String + #2: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.FrameType.String + #3: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.Setting.String + #4: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.SettingID.String + #5: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.bufferedWriterTimeoutWriter.Write + #6: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.chunkWriter.Write + #7: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.duplicatePseudoHeaderError.Error + #8: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldNameError.Error + #9: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldValueError.Error + #10: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.pseudoHeaderError.Error + #11: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.writeData.String + +Vulnerability #4: GO-2026-6603 + HTTP/2 server memory exhaustion due to Trailer headers in net/http + More info: https://pkg.go.dev/vuln/GO-2026-6603 + Module: golang.org/x/net + Found in: golang.org/x/net@v0.33.0 + Fixed in: golang.org/x/net@v0.60.0 + Example traces found: + #1: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.ErrCode.String + #2: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.FrameType.String + #3: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.Setting.String + #4: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.SettingID.String + #5: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.bufferedWriterTimeoutWriter.Write + #6: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.chunkWriter.Write + #7: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.duplicatePseudoHeaderError.Error + #8: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldNameError.Error + #9: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldValueError.Error + #10: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.pseudoHeaderError.Error + #11: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.writeData.String + +Your code is affected by 4 vulnerabilities from 1 module. +This scan also found 14 vulnerabilities in packages you import and 25 +vulnerabilities in modules you require, but your code doesn't appear to call +these vulnerabilities. +Use '-show verbose' for more details. diff --git a/scripts/verifyWebsocketRelease.py b/scripts/verifyWebsocketRelease.py new file mode 100644 index 0000000..7b2deab --- /dev/null +++ b/scripts/verifyWebsocketRelease.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Verify a fixed WebSocket release and generated routes from an empty module cache.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import tempfile +import time + +from verifyErrorInheritanceRelease import run + +ROOT = Path(__file__).resolve().parents[1] +MODULE = "git.yuntuo.hk/k2safe/go-omni" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument("--report", type=Path, default=ROOT / "reports/websocket-remote.json") + args = parser.parse_args() + if not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version): + parser.error("a fixed release version is required") + env = {k: v for k, v in os.environ.items() if not k.startswith("GO_OMNI_")} + env.update(GOWORK="off", GOFLAGS="", GOPRIVATE="git.yuntuo.hk", GONOSUMDB="git.yuntuo.hk") + started = time.monotonic() + with tempfile.TemporaryDirectory(prefix="goOmniWebsocketRelease-") as directory: + temp = Path(directory) + (temp / "bin").mkdir() + env.update(GOBIN=str(temp / "bin"), GOMODCACHE=str(temp / "modcache")) + print(f"Installing remote CLI {args.version} in an empty cache", flush=True) + run(["go", "install", MODULE + "/cmd/go-omni@" + args.version], temp, env) + cli = temp / "bin/go-omni" + starter = temp / "starter" + # Verify the installed default, without --framework-version or a local replace. + run([cli, "new", "--module", "example.com/websocketRelease", starter], temp, env) + run(["go", "mod", "tidy"], starter, env) + dependency = json.loads(run(["go", "list", "-m", "-json", MODULE], starter, env)) + assert dependency["Version"] == args.version and dependency.get("Sum") and not dependency.get("Replace") + downloaded = Path(dependency["Dir"]) + # Add the optional WS dependency only after checking the CLI's default. + run(["go", "get", MODULE + "/ws@" + args.version], starter, env) + print("Testing downloaded WS, Hub, RPC, observation and App integration with race detection", flush=True) + output = run(["go", "test", "-mod=mod", "-race", "-count=1", "-json", "-timeout=180s", + MODULE + "/ws/...", MODULE + "/examples/websocketRPC"], starter, env) + events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] + packages = sorted({e["Package"] for e in events if e.get("Action") == "pass" and "Test" not in e}) + expected = [MODULE + "/ws" + suffix for suffix in ("", "/hub", "/observe", "/omni", "/rpc")] + assert set(expected + [MODULE + "/examples/websocketRPC"]) == set(packages), output + tests = sorted(e["Package"] + ":" + e["Test"] for e in events + if e.get("Action") == "pass" and "Test" in e and "/" not in e["Test"]) + echo = run(["go", "run", "-mod=mod", MODULE + "/examples/websocket"], starter, env) + assert "hello websocket" in echo and "hello group" in echo, echo + rpc = run(["go", "run", "-mod=mod", MODULE + "/examples/websocketRPC"], starter, env) + for generation in (1, 2): + assert f'connection {generation}: double=42, remote callback="generic Go client", restored=1, event={generation}' in rpc, rpc + env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version) + print("Testing generated custom routes and failure cleanup against the fixed remote release", flush=True) + output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=180s", "./cmd/go-omni/apigen", + "-run", "^TestGeneratedCustomRoutesConsumer$"], downloaded, env) + events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] + assert any(e.get("Action") == "pass" and e.get("Test") == "TestGeneratedCustomRoutesConsumer" for e in events), output + evidence = "".join(e.get("Output", "") for e in events) + assert f'checksum {dependency["Sum"]}; no local replace' in evidence, evidence + final_dependency = json.loads(run(["go", "list", "-m", "-json", MODULE], starter, env)) + assert final_dependency["Version"] == args.version and final_dependency["Sum"] == dependency["Sum"] + assert not final_dependency.get("Replace") + report = { + "passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"], + "toolchain": run(["go", "version"], temp, env).strip(), + "freshModuleCache": True, "noLocalReplace": True, + "durationSeconds": round(time.monotonic() - started, 2), + "packages": packages, "tests": tests, + "checks": ["remoteCLIInstall", "installedVersionDefault", "fixedRemoteModuleChecksum", + "downloadedWebsocketRaceTests", "remoteEchoAndGroupExample", "remoteBidirectionalRPCExample", + "remoteTwoConnectionSubscriptionRestore", "generatedCustomRoutesConsumer", + "oldServiceWithoutHook", "handwrittenHookPreserved", "customRouteFailureCleanup"], + "examples": {"websocket": echo.strip(), "websocketRPC": rpc.strip()}, + "sourceHashes": {name: hashlib.sha256((downloaded / name).read_bytes()).hexdigest() for name in + ("scripts/verifyWebsocketRelease.py", "cmd/go-omni/apigen/customRoutes_test.go")}, + "limitations": ["Functional release acceptance; not a vulnerability scan or browser UI test.", + "Known framework HTTP/2 dependency vulnerabilities remain documented in the validation report."], + } + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") + print(f"PASS remote WebSocket release {args.version}; temporary files removed", flush=True) + + +if __name__ == "__main__": + main() diff --git a/ws/README.md b/ws/README.md new file mode 100644 index 0000000..d60ec46 --- /dev/null +++ b/ws/README.md @@ -0,0 +1,149 @@ +# WebSocket + +`ws` 提供通用 Go Client、Server 和单次物理连接 Session。核心使用标准 RFC 6455 WebSocket,可连接浏览器或第三方 WS 服务。`hub`、`rpc`、`observe` 按需引入;不预设用户、租户、设备、错误码或数据存储。 + +- [回显、分组广播与浏览器示例](../examples/websocket/README.md) +- [双向 RPC 与订阅恢复示例](../examples/websocketRPC/README.md) +- [RPC 协议与订阅规则](rpc/README.md) +- [日志与指标适配](observe/README.md) +- [测试与基准报告](../reports/websocket-validation-2026-10-29.md) + +## 独立 Server 与 Client + +```go +server, err := ws.NewServer(ws.ServerConfig{ + MaxConnections: 4096, + // 省略时允许同源和无 Origin 的非浏览器请求。 + AllowedOrigins: []string{"https://console.example.com"}, +}) +if err != nil { return err } +mux.Handle("/ws", server.Handler(ws.Handlers{ + OnMessage: func(ctx context.Context, session *ws.Session, msg ws.Message) error { + return session.Send(ctx, msg) + }, +})) +// HTTP 宿主退出后、依赖释放前调用 server.Shutdown(ctx)。 + +client, err := ws.NewClient(ws.ClientConfig{ProxyMode: "direct"}) +if err != nil { return err } +session, err := client.Dial(workCtx, "wss://service.example.com/ws", ws.Handlers{ + OnMessage: func(ctx context.Context, session *ws.Session, msg ws.Message) error { + // msg.Type 是 ws.Text 或 ws.Binary;此回调中处理业务消息。 + return nil + }, +}) +if err != nil { return err } +err = session.SendJSON(ctx, map[string]string{"message": "hello"}) +// 退出前 client.Shutdown(ctx),确保回调结束后再关闭其依赖。 +``` + +`NewServer` / `NewClient` 只验证并构造,无监听或拨号。Server 是 `http.Handler`,复用 HTTP 监听器及 TLS;客户端 `TLSConfig` 支持系统信任链、自定义 CA 与客户端证书。注入的证书/CA/回调对象由调用方保持并发安全且不再修改。TLS 默认正常验证,不自动跳过验证。 + +`Server.Accept(w,r,handlers)` 会写出握手成功或错误响应,成功后启动托管会话并返回。原 HTTP Handler 可以立即返回;不要保留整个 `*httpx.Context`、Request 或可变鉴权对象。Server 的会话生命周期由 `ServerConfig.Context`(默认 Background)管理,握手 context 仅用于握手。Client.Dial 的 ctx 持有会话寿命,其配置的 HandshakeTimeout 只限制握手。 + +## 握手与安全配置 + +`ServerConfig` 支持 `Session`、`MaxConnections`、`HandshakeTimeout`、`AllowedOrigins`、`RequireOrigin`、`Subprotocols`、`RequireSubprotocol`、`EnableCompression`、`HandshakeRate`、`HandshakeBurst`。连接上限包含进行中的握手。速率为每秒令牌数、burst 为突发数,零 rate 默认关闭;超过速率返回 429,连接上限/停机返回 503。HTTP 读头超时和头大小限制仍由宿主配置。 + +Origin 校验与 CORS 独立: + +- 默认只接受与请求的真实 scheme/host/port 一致的 Origin;缺少 Origin 默认允许,便于 Go 等非浏览器客户端,设置 RequireOrigin 可拒绝。 +- 显式 AllowedOrigins 是完整来源白名单,支持明确的 `http://` / `https://` 和端口;不支持通配 `*`、路径、userinfo、null 或重复 Origin。默认端口归一化。 +- 不信任 Forwarded/X-Forwarded-* 来推断来源。TLS 在代理终止时,应填写实际外部 Origin 白名单。 +- Origin 不是身份认证。`Prepare(ctx,*http.Request)(Handlers,error)` 在升级前验证业务身份,同时可为每连接创建 handlers。失败默认 401;返回 `*ws.RejectError{StatusCode:403}` 可选择 4xx。响应不包含底层错误。 +- 浏览器原生 WebSocket 不能自定义 Authorization Header,业务可采用已受保护的 Cookie 或短期握手票据。若使用 Cookie,继续严格检查 Origin;避免在 URL 中放长期凭证,并配置代理访问日志脱敏。 + +RequireSubprotocol 要求协商成功,否则握手失败;纯 WS 默认无必选子协议。RPC 使用 `go-omni.rpc.v1`,两端显式声明并要求它。 + +## Session 的收发与关闭 + +Session 提供 `ID / Context / Done / Err / Subprotocol / Stats / Send / TrySend / SendJSON / Close / Abort / Wait`。 + +| 操作 | 语义 | +| --- | --- | +| `Send(ctx,Message)` | 有界队列入场后等待写入结果;队列满立即返回 ErrQueueFull,不无限等待空位。 | +| `TrySend(Message)` | 只确认入队,随后仍可能写失败;用于广播、单向通知。 | +| `SendJSON(ctx,value)` | JSON 编码后按 Text 发送,适用于应用自己的协议。 | +| `Close(code,reason)` | 幂等启动关闭,取消回调 context,尽力发送已接纳且未取消的出站消息,再发关闭帧;不等待自身回调。 | +| `Abort()` | 立即关闭 socket,仍需 Wait 才能证明回调退出。 | +| `Wait(ctx)` / `Done()` | 等待读、写、分发、监控及 OnClose 全部结束;Wait 返回运行错误或等待超时。 | + +文本严格验证 UTF-8;二进制保持字节内容。入队复制发送 payload,Send/TrySend 返回后调用方可以复用原切片。不得在调用进行中并发修改它。接收 payload 不复用,回调可以保留,但应用自行承担保留后的内存。业务不得直接抢占底层 reader/writer。 + +单个会话 OnOpen、OnMessage 顺序执行,OnClose 在 I/O 和消息回调结束后执行;不同会话可并发。回调必须响应 ctx 取消,不要在回调内调用自身 Wait、Client/Server.Shutdown 或 App.Close,可调用 Session.Close 或 Server.Stop。OnOpen 中不要等待必须依赖 OnMessage 才完成的操作;RPC Call/订阅恢复应在 WaitReady 后从独立受管理任务调用。 + +排队时 Send ctx 取消会跳过该消息;写入期间取消可能已部分送出,此时终止物理连接。成功写出也不代表对端业务处理成功。框架不缓存离线消息、不自动重放;业务需要 ACK、去重、持久化时显式设计。Close 不是业务事务提交屏障,会取消当前业务并丢弃尚未分发的入站消息。 + +## 资源上限与心跳 + +`SessionConfig` 零字段使用默认值,负值或超过支持边界的值在构造前报错。明确关闭心跳使用 `DisableHeartbeat`,零时间不表示禁用。 + +| 配置 | 默认 | +| --- | --- | +| MaxMessageBytes | 1 MiB,包括解压后的消息 | +| ReadQueueSize / WriteQueueSize | 每方向 64 条 | +| ReadQueueBytes / WriteQueueBytes | 每方向 4 MiB | +| WriteTimeout | 10 秒 | +| PingInterval / PongTimeout | 30 / 60 秒 | +| CloseTimeout | 5 秒 | + +队列计数包含正在执行的回调/正在写的消息;此外还需计入一条正在解码的消息、协议缓冲和应用持有的对象。实例总内存还受连接数影响。接收队列溢出关闭该连接;发送满明确报错,广播逐连接返回错误。有限队列允许持续处理控制帧,不让慢业务无限阻塞 reader。压缩默认关闭;启用后仍执行解压大小限制,但会消耗额外 CPU,应结合并发连接限额验证。 + +协议 Ping/Pong 由底层持续处理;浏览器自动回复 Pong。心跳监测连接活性,不证明业务健康。 + +## Client 重连与网络设置 + +`ClientConfig` 支持 Session、HandshakeTimeout(默认 10s)、MaxConnections(默认 64)、Subprotocols/RequireSubprotocol、EnableCompression、ProxyMode/ProxyURL、TLSConfig、Headers、HeaderProvider、Prepare、Reconnect、ShouldReconnect。 + +- `Dial` 只拨一次。`Run(ctx,url,handlers)` 管理一条重连链;同 Client 可以并发运行多条连接,统一限制和关闭。 +- `Reconnect.Enabled` 显式开启;默认 minBackoff=100ms、maxBackoff=30s、stableAfter=30s。指数退避带抖动;短暂反复断线不重置预算。MaxAttempts/MaxElapsed 可限制重试次数和时间;开启重连后两者为零表示不限。 +- 默认不重试 401/403、协议不兼容、证书验证错误、正常 1000 关闭;远端先发 1001(going away)、1012 等重启关闭码和临时网络故障可重试。本地主动 Close/Client.Stop 不触发重连。ShouldReconnect 可改变分类,不能绕过显式关闭和预算。 +- 每次握手调用 HeaderProvider(ctx),用于刷新凭证;返回头会复制。Prepare(ctx) 可为每代创建新的 RPC Peer/handlers,禁止复用已经绑定的 Peer。 +- ProxyMode 默认为 environment,可显式 direct 或 url;固定代理支持 HTTP CONNECT、socks5、socks5h。HTTPS 代理当前明确拒绝,不会降级直连。 +- 自有 HTTP 包装链 `httpc.Do`、`telemetry.Transport`、`resilience.Transport` 不直接用于 WS 升级;WS 独立处理握手、消息与连接寿命。 + +`Client.Close` / `Stop` 停止入场、取消握手/退避并发起会话关闭;`Drain` / `Shutdown` 等待所有 Run、握手、会话和回调结束。Server 同样提供 Stop/Drain/Shutdown。终止后对象不可重新启动。 + +## Hub 与扩展 + +`hub.New(Config)` 提供 `Add / Remove / Join / Leave / Send / SendGroup / Broadcast / Kick / Stats / Close`。Session 实现其 `Connection` 接口,也可实现其他有界非阻塞连接适配器。组键是业务自定义字符串,Join 前由业务鉴权。 + +Send/Broadcast/SendGroup 返回 `Result{Attempted,Queued,Errors}`,逐连接尝试入队,慢连接不会等待网络写出。Queued 不等于远端收到。Hub 借用连接,Close 只清注册和 watcher;Kick 才主动关闭,Session.Done 后自动清理。上限可配置连接数、组数、总成员关系、每连接组数量。 + +Hub 是单进程注册表。跨节点广播由业务消息总线投递到各节点的本地 Hub;此版不含分布式路由、离线存储或 exactly-once 保证。通过接口扩展时仍需保持 TrySend 有界、非阻塞、并发安全。 + +## GoOmni 接入与停机 + +独立 App: + +```go +application := omni.New() +server, err := ws.NewServer(ws.ServerConfig{}) +if err != nil { return err } +if err = wsomni.Mount(application, "/ws", server, handlers); err != nil { return err } +// Mount 同时登记 Server.Stop/Drain,App.OnShutdown 的资源晚于会话释放。 +return application.RunContext(ctx, ":8080") +``` + +`wsomni` 的 import 是 `git.yuntuo.hk/k2safe/go-omni/ws/omni`。App 新增通用 `OnStop(name,hook)` / `OnDrain(name,hook)`:HTTP 结束 → 全部 Stop → 全部 Drain → OnShutdown。复用 lifecycle 配置及 ErrResourcesRetained 规则;只在用到后台组件时创建额外 Runtime。 + +生成项目在 `svc.Init` 创建 server,Context 设为 svc.WorkContext,并登记 `svc.OnStop("ws",server.Stop)` / `svc.OnDrain("ws",server.Drain)`。项目可手写以下方法,新版生成 NewApp 自动调用: + +```go +func (c *ServiceContext) RegisterCustomRoutes(app *omni.App) error { + app.GET("/ws", wsomni.Handler(c.WebSocket, c.WebSocketHandlers)) + return nil +} +``` + +不要同时把同一组件登记到 App 和 svc;依赖归属与排空屏障必须一致。自定义路由在生成路由与内建端点之后注册,重复路径由路由器拒绝,不会覆盖现有接口。重新运行 `api generate` 更新入口,无需修改受管文件;普通项目没有此方法时行为不变。握手成功会记录 HTTP 101,HTTP 日志只计算握手时间。 + +ServerConfig/ClientConfig 直接组合进项目的 Config/Default/Validate;文件参数仍采用显式 `${NAME}` 引用。框架不新增 WS DSL 或自动业务 Token。 + +兼容范围:默认 nethttp 的 HTTP/1.1 Upgrade、标准 HTTP/1.1 反向代理、WS/WSS。自研 native 和独立实验 http2 Runner 目前不支持 WS;Mount 在配置时拒绝,普通 Handler 遇到不支持接管的 ResponseWriter 返回 501。不宣称支持 HTTP/2 extended CONNECT。切换 Runner 时仍需遵守此边界。 + +## 错误与观测 + +`errors.Is` 判断 ErrClosed、ErrNotConnected、ErrQueueFull、ErrMessageTooLarge、ErrInvalidMessage、ErrUnsupportedTransport、ErrConnectionLimit;`errors.As` 获取 HandshakeError.StatusCode、CloseError.Code、OpError.Op。Cause 保留诊断链,但可能包含敏感数据;默认错误文本不包含 URL、认证头、payload 或对端 reason,不要无条件递归记录 Cause。 + +Server.Stats 提供握手数、活跃连接、累计接受/拒绝;Client.Stats 提供活跃连接、握手、Run数、关闭状态及累计 DialAttempts、HandshakeFailures、Reconnects;Session.Stats 提供消息/字节和队列占用。`observe` 提供安全 logx 日志和跨会话统计快照,可由项目导出到 metrics/OTel。连接 ID 不适合作为指标标签;不要把整条长连接当成持续数小时的 HTTP Span。业务消息 tracing 由业务协议显式传播。 diff --git a/ws/client.go b/ws/client.go new file mode 100644 index 0000000..0f6c66c --- /dev/null +++ b/ws/client.go @@ -0,0 +1,633 @@ +package ws + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "io" + "math/rand" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +var ErrConnectionLimit = errors.New("ws: connection limit reached") + +// ReconnectConfig controls Run only. Dial never retries. Zero MaxAttempts and +// MaxElapsed mean unlimited retries/time when Enabled is explicitly true. +// A stable connection resets both retry budgets; short flaps do not. +type ReconnectConfig struct { + Enabled bool `json:"enabled"` + MinBackoff time.Duration `json:"minBackoff"` + MaxBackoff time.Duration `json:"maxBackoff"` + StableAfter time.Duration `json:"stableAfter"` + MaxAttempts int `json:"maxAttempts"` + MaxElapsed time.Duration `json:"maxElapsed"` +} + +type ClientConfig struct { + Session SessionConfig `json:"session"` + HandshakeTimeout time.Duration `json:"handshakeTimeout"` + MaxConnections int `json:"maxConnections"` + Subprotocols []string `json:"subprotocols"` + RequireSubprotocol bool `json:"requireSubprotocol"` + EnableCompression bool `json:"enableCompression"` + // ProxyMode is environment (default), direct, or url. ProxyURL may contain + // credentials and must not be logged. Explicit proxy URLs ignore NO_PROXY. + // Supported proxy schemes are http, socks5, and socks5h (remote DNS). + ProxyMode string `json:"proxyMode"` + ProxyURL string `json:"proxyUrl"` + Reconnect ReconnectConfig `json:"reconnect"` + Headers http.Header `json:"-"` + // HeaderProvider is called before each handshake. Return a fresh header map; + // the client snapshots it. Provider values replace static Headers values. + // Implementations must honor context cancellation and support concurrent calls. + HeaderProvider func(context.Context) (http.Header, error) `json:"-"` + // Prepare creates per-session handlers after a successful handshake. When + // set, its result replaces Dial/Run handlers. It must honor cancellation. + Prepare func(context.Context) (Handlers, error) `json:"-"` + TLSConfig *tls.Config `json:"-"` + // ShouldReconnect overrides default error classification, not budgets or + // explicit client/context shutdown. It must be safe for concurrent Run calls. + ShouldReconnect func(error) bool `json:"-"` +} + +func (cfg ClientConfig) Validate() error { _, _, err := normalizeClientConfig(cfg); return err } + +func normalizeClientConfig(cfg ClientConfig) (ClientConfig, func(*http.Request) (*url.URL, error), error) { + var err error + cfg.Session, err = normalizeSessionConfig(cfg.Session) + if err != nil { + return cfg, nil, err + } + if cfg.HandshakeTimeout < 0 || cfg.MaxConnections < 0 || cfg.MaxConnections > 65536 || cfg.Reconnect.MinBackoff < 0 || cfg.Reconnect.MaxBackoff < 0 || cfg.Reconnect.StableAfter < 0 || cfg.Reconnect.MaxElapsed < 0 || cfg.Reconnect.MaxAttempts < 0 { + return cfg, nil, errors.New("ws: invalid client limits or durations") + } + if cfg.HandshakeTimeout == 0 { + cfg.HandshakeTimeout = 10 * time.Second + } + if cfg.MaxConnections == 0 { + cfg.MaxConnections = 64 + } + if cfg.Reconnect.MinBackoff == 0 { + cfg.Reconnect.MinBackoff = 100 * time.Millisecond + } + if cfg.Reconnect.MaxBackoff == 0 { + cfg.Reconnect.MaxBackoff = 30 * time.Second + } + if cfg.Reconnect.StableAfter == 0 { + cfg.Reconnect.StableAfter = 30 * time.Second + } + if cfg.Reconnect.MinBackoff > cfg.Reconnect.MaxBackoff { + return cfg, nil, errors.New("ws: minBackoff exceeds maxBackoff") + } + cfg.Headers, err = clientHeaders(cfg.Headers) + if err != nil { + return cfg, nil, err + } + if len(cfg.Subprotocols) > 32 { + return cfg, nil, errors.New("ws: too many subprotocols") + } + seen := map[string]bool{} + for _, protocol := range cfg.Subprotocols { + if !headerToken(protocol) || len(protocol) > 256 || seen[protocol] { + return cfg, nil, errors.New("ws: invalid or duplicate subprotocol") + } + seen[protocol] = true + } + if cfg.RequireSubprotocol && len(cfg.Subprotocols) == 0 { + return cfg, nil, errors.New("ws: required subprotocol list is empty") + } + cfg.Subprotocols = append([]string(nil), cfg.Subprotocols...) + if cfg.TLSConfig != nil { + cfg.TLSConfig = cfg.TLSConfig.Clone() + } + if cfg.ProxyMode != "url" && cfg.ProxyURL != "" { + return cfg, nil, errors.New("ws: proxyUrl requires url proxyMode") + } + var proxy func(*http.Request) (*url.URL, error) + switch cfg.ProxyMode { + case "", "environment": + proxy = func(r *http.Request) (*url.URL, error) { + u, err := http.ProxyFromEnvironment(r) + if err != nil || u == nil { + return u, err + } + return supportedProxy(u) + } + case "direct": + case "url": + u, e := url.Parse(cfg.ProxyURL) + if e != nil || u == nil || u.Hostname() == "" || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Path != "" && u.Path != "/") || !validPort(u) { + return cfg, nil, errors.New("ws: invalid proxy URL") + } + u, e = supportedProxy(u) + if e != nil { + return cfg, nil, e + } + proxy = http.ProxyURL(u) + default: + return cfg, nil, errors.New("ws: invalid proxyMode") + } + return cfg, proxy, nil +} + +func supportedProxy(u *url.URL) (*url.URL, error) { + copyURL := *u + switch copyURL.Scheme { + case "http", "socks5": + case "socks5h": + copyURL.Scheme = "socks5" + default: + return nil, errors.New("ws: proxy scheme must be http, socks5 or socks5h") + } + return ©URL, nil +} + +func validPort(u *url.URL) bool { + if strings.HasSuffix(u.Host, ":") { + return false + } + if p := u.Port(); p != "" { + n, err := strconv.Atoi(p) + return err == nil && n > 0 && n <= 65535 + } + return true +} + +func headerToken(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(c)) { + continue + } + return false + } + return true +} +func clientHeaders(input http.Header) (http.Header, error) { + if len(input) > 128 { + return nil, errors.New("ws: too many handshake headers") + } + out := make(http.Header, len(input)) + size := 0 + for key, values := range input { + name := http.CanonicalHeaderKey(key) + if !headerToken(key) || len(key) > 256 { + return nil, errors.New("ws: invalid handshake header name") + } + switch name { + case "Host", "Connection", "Upgrade", "Sec-Websocket-Key", "Sec-Websocket-Version", "Sec-Websocket-Extensions", "Sec-Websocket-Protocol", "Content-Length", "Transfer-Encoding", "Trailer": + return nil, errors.New("ws: reserved handshake header") + } + if _, ok := out[name]; ok { + return nil, errors.New("ws: duplicate handshake header") + } + for _, value := range values { + size += len(key) + len(value) + for i := 0; i < len(value); i++ { + if value[i] < 32 && value[i] != '\t' || value[i] == 127 { + return nil, errors.New("ws: invalid handshake header value") + } + } + } + if size > 64<<10 { + return nil, errors.New("ws: handshake headers exceed limit") + } + out[name] = append([]string(nil), values...) + } + return out, nil +} + +// Client owns its sessions. Concurrent Dial and Run calls are independent and +// share only limits and lifecycle. One Run owns one reconnecting connection; +// every successful handshake produces a new Session with a new ID. +type Client struct { + config ClientConfig + dialer websocket.Dialer + ctx context.Context + cancel context.CancelFunc + mu sync.Mutex + closed bool + active int + connections int + dialAttempts, handshakeFailures, reconnects uint64 + sessions map[string]*Session + releases map[string]func() + done chan struct{} +} + +func NewClient(cfg ClientConfig) (*Client, error) { + cfg, proxy, err := normalizeClientConfig(cfg) + if err != nil { + return nil, err + } + ctx, cancel := context.WithCancel(context.Background()) + return &Client{config: cfg, dialer: websocket.Dialer{Proxy: proxy, HandshakeTimeout: cfg.HandshakeTimeout, TLSClientConfig: cfg.TLSConfig, Subprotocols: cfg.Subprotocols, EnableCompression: cfg.EnableCompression}, ctx: ctx, cancel: cancel, sessions: make(map[string]*Session), releases: make(map[string]func()), done: make(chan struct{})}, nil +} + +func (c *Client) begin(connection bool) error { + for { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return ErrClosed + } + if connection && c.connections >= c.config.MaxConnections { + // Done may wake a caller before its registry watcher gets CPU time. + // Retire finished sessions synchronously before reporting overload. + var release func() + for id, s := range c.sessions { + select { + case <-s.Done(): + release = c.releases[id] + default: + } + if release != nil { + break + } + } + c.mu.Unlock() + if release != nil { + release() + continue + } + return ErrConnectionLimit + } + c.active++ + if connection { + c.connections++ + c.dialAttempts++ + } + c.mu.Unlock() + return nil + } +} +func (c *Client) end(connection bool, id string) { + c.mu.Lock() + defer c.mu.Unlock() + if id != "" { + delete(c.sessions, id) + delete(c.releases, id) + } + c.active-- + if connection { + c.connections-- + } + if c.closed && c.active == 0 { + close(c.done) + } +} + +// Dial performs exactly one handshake. Its timeout ends at the handshake; +// ctx continues to own the resulting session's lifetime. No message is replayed. +func (c *Client) Dial(ctx context.Context, endpoint string, handlers Handlers) (*Session, error) { + return c.dial(ctx, endpoint, handlers, 0) +} +func (c *Client) dial(ctx context.Context, endpoint string, handlers Handlers, budget time.Duration) (*Session, error) { + if ctx == nil { + return nil, errors.New("ws: nil context") + } + if err := ctx.Err(); err != nil { + return nil, err + } + u, err := url.Parse(endpoint) + if err != nil || u == nil || u.Hostname() == "" || u.User != nil || u.Fragment != "" || u.Opaque != "" || (u.Scheme != "ws" && u.Scheme != "wss") || !validPort(u) { + return nil, errors.New("ws: invalid endpoint") + } + if err = c.begin(true); err != nil { + return nil, err + } + transferred := false + sessionCtx, cancelSession := context.WithCancel(ctx) + stopSessionShutdown := context.AfterFunc(c.ctx, cancelSession) + defer func() { + if !transferred { + c.mu.Lock() + c.handshakeFailures++ + c.mu.Unlock() + stopSessionShutdown() + cancelSession() + c.end(true, "") + } + }() + handshakeTimeout := c.config.HandshakeTimeout + if budget > 0 && budget < handshakeTimeout { + handshakeTimeout = budget + } + hctx, cancel := context.WithTimeout(ctx, handshakeTimeout) + stop := context.AfterFunc(c.ctx, cancel) + defer func() { stop(); cancel() }() + headers := c.config.Headers.Clone() + if c.config.HeaderProvider != nil { + provided, e := provideHeaders(c.config.HeaderProvider, hctx) + if e != nil { + return nil, &OpError{Op: "headers", Cause: e} + } + provided, e = clientHeaders(provided) + if e != nil { + return nil, &OpError{Op: "headers", Cause: e} + } + for k, v := range provided { + headers[k] = v + } + } + dialer := c.dialer + // Gorilla applies the context deadline to handshake I/O, but canceling a + // deadline-less context after TCP connect does not interrupt its HTTP read. + // Retain the raw socket only through the handshake so Close/Stop cancels + // stalled HTTP and proxy handshakes immediately, without timing out sessions. + var stopHandshake func() bool + dialer.NetDialContext = func(dialctx context.Context, network, address string) (net.Conn, error) { + conn, err := (&net.Dialer{Timeout: c.config.HandshakeTimeout, KeepAlive: 30 * time.Second}).DialContext(dialctx, network, address) + if err == nil { + stopHandshake = context.AfterFunc(hctx, func() { _ = conn.Close() }) + } + return conn, err + } + if dialer.TLSClientConfig != nil { + dialer.TLSClientConfig = dialer.TLSClientConfig.Clone() + } + conn, response, err := dialer.DialContext(hctx, endpoint, headers) + if stopHandshake != nil { + stopHandshake() + } + if err == nil && hctx.Err() != nil { + _ = conn.Close() + err = hctx.Err() + } + if err != nil { + status := 0 + if response != nil { + status = response.StatusCode + if response.Body != nil { + _ = response.Body.Close() + } + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + if c.ctx.Err() != nil { + return nil, ErrClosed + } + if hctx.Err() != nil { + err = hctx.Err() + } else if deadline, ok := hctx.Deadline(); ok && !time.Now().Before(deadline) { + var timed net.Error + if errors.As(err, &timed) && timed.Timeout() { + err = errors.Join(context.DeadlineExceeded, err) + } + } + return nil, &HandshakeError{StatusCode: status, Cause: err} + } + if c.config.RequireSubprotocol && conn.Subprotocol() == "" { + _ = conn.Close() + return nil, &HandshakeError{StatusCode: http.StatusSwitchingProtocols, Cause: websocket.ErrBadHandshake} + } + if c.config.Prepare != nil { + handlers, err = prepareClient(c.config.Prepare, sessionCtx) + if err != nil { + _ = conn.Close() + return nil, &OpError{Op: "prepare", Cause: err} + } + } + c.mu.Lock() + if c.closed { + c.mu.Unlock() + _ = conn.Close() + return nil, ErrClosed + } + s := newSession(conn, c.config.Session, handlers, sessionCtx) + c.sessions[s.ID()] = s + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { cancelSession(); c.end(true, s.ID()) }) } + c.releases[s.ID()] = release + transferred = true + stopSessionShutdown() + c.mu.Unlock() + s.start() + go func() { <-s.Done(); release() }() + return s, nil +} + +func provideHeaders(fn func(context.Context) (http.Header, error), ctx context.Context) (headers http.Header, err error) { + defer func() { + if recover() != nil { + err = errors.New("ws: header provider panicked") + } + }() + return fn(ctx) +} +func prepareClient(fn func(context.Context) (Handlers, error), ctx context.Context) (handlers Handlers, err error) { + defer func() { + if recover() != nil { + err = errors.New("ws: prepare callback panicked") + } + }() + return fn(ctx) +} + +// Run blocks until canceled, closed, or retry policy/budget stops it. Reconnect +// must be enabled explicitly. Handlers are reused; OnOpen must restore any +// application subscriptions. Credentials are refreshed by HeaderProvider. +func (c *Client) Run(ctx context.Context, endpoint string, handlers Handlers) error { + if ctx == nil { + return errors.New("ws: nil context") + } + if err := c.begin(false); err != nil { + return err + } + defer c.end(false, "") + runctx, cancel := context.WithCancel(ctx) + stop := context.AfterFunc(c.ctx, cancel) + defer func() { stop(); cancel() }() + attempts := 0 + episode := time.Now() + var previousError error = context.DeadlineExceeded + repeat := false + for { + var remaining time.Duration + if c.config.Reconnect.Enabled && c.config.Reconnect.MaxElapsed > 0 { + remaining = c.config.Reconnect.MaxElapsed - time.Since(episode) + if remaining <= 0 { + return previousError + } + } + started := time.Now() + if repeat { + c.mu.Lock() + c.reconnects++ + c.mu.Unlock() + } + repeat = true + s, err := c.dial(runctx, endpoint, handlers, remaining) + if err == nil { + started = time.Now() + err = s.Wait(runctx) + if time.Since(started) >= c.config.Reconnect.StableAfter { + attempts = 0 + episode = time.Now() + } + } + if ctx.Err() != nil { + return ctx.Err() + } + if c.ctx.Err() != nil { + return nil + } + if !c.config.Reconnect.Enabled || !c.retryable(err) { + return err + } + previousError = err + rc := c.config.Reconnect + if rc.MaxAttempts > 0 && attempts >= rc.MaxAttempts { + return err + } + delay := retryBackoff(rc, attempts) + if rc.MaxElapsed > 0 && time.Since(episode)+delay >= rc.MaxElapsed { + return err + } + attempts++ + timer := time.NewTimer(delay) + select { + case <-runctx.Done(): + timer.Stop() + if ctx.Err() != nil { + return ctx.Err() + } + return nil + case <-timer.C: + } + } +} + +func retryBackoff(cfg ReconnectConfig, attempt int) time.Duration { + d := cfg.MinBackoff + for i := 0; i < attempt && d < cfg.MaxBackoff; i++ { + if d > cfg.MaxBackoff/2 { + d = cfg.MaxBackoff + } else { + d *= 2 + } + } + if d > 1 { + d = d/2 + time.Duration(rand.Int63n(int64(d-d/2))+1) + } + return d +} +func (c *Client) retryable(err error) bool { + if err == nil || errors.Is(err, ErrClosed) || errors.Is(err, context.Canceled) { + return false + } + if c.config.ShouldReconnect != nil { + return c.config.ShouldReconnect(err) + } + var handshake *HandshakeError + if errors.As(err, &handshake) { + switch handshake.StatusCode { + case 429, 500, 502, 503, 504: + return true + case 0: + default: + return false + } + if errors.Is(err, websocket.ErrBadHandshake) { + return false + } + } + var unknown x509.UnknownAuthorityError + var invalid x509.CertificateInvalidError + var hostname x509.HostnameError + if errors.As(err, &unknown) || errors.As(err, &invalid) || errors.As(err, &hostname) { + return false + } + var closeError *CloseError + if errors.As(err, &closeError) { + switch closeError.Code { + case 1001, 1006, 1011, 1012, 1013: + return true + default: + return false + } + } + var network net.Error + if errors.As(err, &network) || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return true + } + return handshake != nil || errors.Is(err, ErrNotConnected) +} + +// Close is idempotent and nonblocking: it rejects new work, cancels handshakes +// and reconnect waits, and requests session close. Drain waits for all work, +// including user callbacks. A callback that ignores cancellation cannot be killed. +func (c *Client) Close() error { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return nil + } + c.closed = true + sessions := make([]*Session, 0, len(c.sessions)) + for _, s := range c.sessions { + sessions = append(sessions, s) + } + if c.active == 0 { + close(c.done) + } + c.mu.Unlock() + c.cancel() + for _, s := range sessions { + _ = s.Close(1001, "client shutdown") + } + return nil +} +func (c *Client) Stop(context.Context) error { return c.Close() } +func (c *Client) Drain(ctx context.Context) error { + if ctx == nil { + return errors.New("ws: nil context") + } + select { + case <-c.done: + return nil + case <-ctx.Done(): + c.mu.Lock() + sessions := make([]*Session, 0, len(c.sessions)) + for _, s := range c.sessions { + sessions = append(sessions, s) + } + c.mu.Unlock() + for _, s := range sessions { + s.Abort() + } + return ctx.Err() + } +} +func (c *Client) Shutdown(ctx context.Context) error { _ = c.Close(); return c.Drain(ctx) } + +type ClientStats struct { + ActiveConnections int + Handshakes int + Running int + Closed bool + // DialAttempts includes admitted attempts canceled before the HTTP handshake. + DialAttempts uint64 + HandshakeFailures uint64 + // Reconnects counts subsequent Dial attempts made by Run, successful or not. + Reconnects uint64 +} + +func (c *Client) Stats() ClientStats { + c.mu.Lock() + defer c.mu.Unlock() + return ClientStats{ActiveConnections: len(c.sessions), Handshakes: c.connections - len(c.sessions), Running: c.active - c.connections, Closed: c.closed, DialAttempts: c.dialAttempts, HandshakeFailures: c.handshakeFailures, Reconnects: c.reconnects} +} diff --git a/ws/client_test.go b/ws/client_test.go new file mode 100644 index 0000000..117bb89 --- /dev/null +++ b/ws/client_test.go @@ -0,0 +1,494 @@ +package ws + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func clientTestConfig() ClientConfig { + return ClientConfig{ProxyMode: "direct", Session: SessionConfig{CloseTimeout: 100 * time.Millisecond}} +} +func clientForTest(t *testing.T, cfg ClientConfig) *Client { + t.Helper() + c, err := NewClient(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := c.Shutdown(ctx); err != nil { + t.Errorf("client shutdown: %v", err) + } + }) + return c +} +func echoWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + for { + kind, data, err := conn.ReadMessage() + if err != nil { + return + } + if err = conn.WriteMessage(kind, data); err != nil { + return + } + } +} +func wsURL(s *httptest.Server) string { return "ws" + strings.TrimPrefix(s.URL, "http") } +func receiveClientMessage(t *testing.T, ch <-chan Message) Message { + t.Helper() + select { + case m := <-ch: + return m + case <-time.After(2 * time.Second): + t.Fatal("message timeout") + return Message{} + } +} + +func TestClientHandshakeDeadlineDoesNotExpireSession(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(echoWebSocket)) + defer server.Close() + cfg := clientTestConfig() + cfg.HandshakeTimeout = 40 * time.Millisecond + c := clientForTest(t, cfg) + messages := make(chan Message, 2) + s, err := c.Dial(context.Background(), wsURL(server), Handlers{OnMessage: func(_ context.Context, _ *Session, m Message) error { messages <- m; return nil }}) + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(100 * time.Millisecond) + <-timer.C + for _, kind := range []MessageType{Text, Binary} { + if err = s.Send(context.Background(), Message{Type: kind, Data: []byte("alive")}); err != nil { + t.Fatal(err) + } + got := receiveClientMessage(t, messages) + if got.Type != kind || string(got.Data) != "alive" { + t.Fatalf("unexpected echo: %#v", got) + } + } + _ = s.Close(1000, "") +} + +func TestClientTLSAndMutualTLS(t *testing.T) { + for _, mutual := range []bool{false, true} { + t.Run(fmt.Sprint(mutual), func(t *testing.T) { + server := httptest.NewUnstartedServer(http.HandlerFunc(echoWebSocket)) + if mutual { + server.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert} + } + server.StartTLS() + defer server.Close() + roots := x509.NewCertPool() + roots.AddCert(server.Certificate()) + cfg := clientTestConfig() + cfg.TLSConfig = &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12} + if mutual { + cfg.TLSConfig.Certificates = append([]tls.Certificate(nil), server.TLS.Certificates...) + } + c := clientForTest(t, cfg) + cfg.TLSConfig.ServerName = "wrong-after-construction.invalid" + messages := make(chan Message, 1) + s, err := c.Dial(context.Background(), wsURL(server), Handlers{OnMessage: func(_ context.Context, _ *Session, m Message) error { messages <- m; return nil }}) + if err != nil { + t.Fatal(err) + } + if err = s.Send(context.Background(), Message{Type: Text, Data: []byte("tls")}); err != nil { + t.Fatal(err) + } + if string(receiveClientMessage(t, messages).Data) != "tls" { + t.Fatal("bad echo") + } + _ = s.Close(1000, "") + }) + } +} + +func TestClientHeadersRefreshReconnectAndFreshSessions(t *testing.T) { + var accepted atomic.Int32 + firstOpen := make(chan struct{}) + var firstOpenOnce sync.Once + headerValues := make(chan string, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headerValues <- r.Header.Get("Authorization") + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + if accepted.Add(1) == 1 { + select { + case <-firstOpen: + case <-r.Context().Done(): + return + } + _ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(1012, "restart"), time.Now().Add(time.Second)) + return + } + for { + if _, _, err = conn.ReadMessage(); err != nil { + return + } + } + })) + defer server.Close() + var providerCalls atomic.Int32 + var prepareCalls atomic.Int32 + ids := make(chan string, 4) + cfg := clientTestConfig() + cfg.Reconnect = ReconnectConfig{Enabled: true, MinBackoff: time.Millisecond, MaxBackoff: 5 * time.Millisecond, StableAfter: time.Second, MaxAttempts: 3} + cfg.HeaderProvider = func(context.Context) (http.Header, error) { + n := providerCalls.Add(1) + return http.Header{"Authorization": {fmt.Sprint(n)}}, nil + } + cfg.Prepare = func(ctx context.Context) (Handlers, error) { + prepareCalls.Add(1) + return Handlers{OnOpen: func(sessionCtx context.Context, s *Session) error { + if ctx.Err() != nil || sessionCtx.Err() != nil { + return errors.New("prepare context canceled early") + } + ids <- s.ID() + firstOpenOnce.Do(func() { close(firstOpen) }) + return nil + }}, nil + } + c := clientForTest(t, cfg) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + finished := make(chan error, 1) + go func() { finished <- c.Run(ctx, wsURL(server), Handlers{}) }() + var first, second string + select { + case first = <-ids: + case <-time.After(2 * time.Second): + t.Fatal("first open timeout") + } + select { + case second = <-ids: + case <-time.After(2 * time.Second): + t.Fatal("reopen timeout") + } + if first == second { + t.Fatal("reused physical session ID") + } + if <-headerValues != "1" || <-headerValues != "2" { + t.Fatal("headers not refreshed") + } + if prepareCalls.Load() != 2 { + t.Fatal("prepare not recreated") + } + cancel() + select { + case err := <-finished: + if !errors.Is(err, context.Canceled) { + t.Fatalf("run: %v", err) + } + case <-time.After(time.Second): + t.Fatal("run cancel timeout") + } +} + +func TestClientDoesNotReconnectAuthenticationOrProtocolFailure(t *testing.T) { + for _, status := range []int{400, 401, 403, 404} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.WriteHeader(status) + _, _ = w.Write([]byte("secret response")) + })) + defer server.Close() + cfg := clientTestConfig() + cfg.Reconnect.Enabled = true + c := clientForTest(t, cfg) + err := c.Run(context.Background(), wsURL(server)+"/?token=TOPSECRET", Handlers{}) + var handshake *HandshakeError + if !errors.As(err, &handshake) || handshake.StatusCode != status { + t.Fatalf("error: %v", err) + } + if calls.Load() != 1 || strings.Contains(err.Error(), "SECRET") || strings.Contains(err.Error(), "secret") { + t.Fatalf("retry/leak: calls=%d error=%v", calls.Load(), err) + } + }) + } +} + +func TestClientCloseCancelsHandshakeAndBackoff(t *testing.T) { + t.Run("handshake", func(t *testing.T) { + entered := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(entered); <-r.Context().Done() })) + defer server.Close() + c := clientForTest(t, clientTestConfig()) + finished := make(chan error, 1) + go func() { _, err := c.Dial(context.Background(), wsURL(server), Handlers{}); finished <- err }() + <-entered + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := c.Shutdown(ctx); err != nil { + t.Fatal(err) + } + if err := <-finished; !errors.Is(err, ErrClosed) { + t.Fatalf("dial: %v", err) + } + }) + t.Run("backoff", func(t *testing.T) { + entered := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(entered); w.WriteHeader(503) })) + defer server.Close() + cfg := clientTestConfig() + cfg.Reconnect = ReconnectConfig{Enabled: true, MinBackoff: time.Hour, MaxBackoff: time.Hour} + c := clientForTest(t, cfg) + finished := make(chan error, 1) + go func() { finished <- c.Run(context.Background(), wsURL(server), Handlers{}) }() + <-entered + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := c.Shutdown(ctx); err != nil { + t.Fatal(err) + } + if err := <-finished; err != nil { + t.Fatal(err) + } + }) +} + +func TestClientConcurrentDialLimitAndClose(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(echoWebSocket)) + defer server.Close() + cfg := clientTestConfig() + cfg.MaxConnections = 2 + c := clientForTest(t, cfg) + var wg sync.WaitGroup + var successes atomic.Int32 + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := c.Dial(context.Background(), wsURL(server), Handlers{}) + if err == nil { + successes.Add(1) + } else if !errors.Is(err, ErrConnectionLimit) { + t.Errorf("dial: %v", err) + } + }() + } + wg.Wait() + if successes.Load() != 2 { + t.Fatalf("connections=%d", successes.Load()) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := c.Shutdown(ctx); err != nil { + t.Fatal(err) + } + if _, err := c.Dial(ctx, wsURL(server), Handlers{}); !errors.Is(err, ErrClosed) { + t.Fatalf("closed dial: %v", err) + } +} + +func TestClientRetryBudgetAndValidation(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1); w.WriteHeader(503) })) + defer server.Close() + cfg := clientTestConfig() + cfg.Reconnect = ReconnectConfig{Enabled: true, MinBackoff: time.Millisecond, MaxBackoff: 2 * time.Millisecond, MaxAttempts: 2} + c := clientForTest(t, cfg) + if err := c.Run(context.Background(), wsURL(server), Handlers{}); err == nil { + t.Fatal("wanted failure") + } + if calls.Load() != 3 { + t.Fatalf("retry budget: %d", calls.Load()) + } + for _, endpoint := range []string{"https://example.test", "ws://user:password@example.test/", "ws://", "ws://example.test/#fragment"} { + if _, err := c.Dial(context.Background(), endpoint, Handlers{}); err == nil || strings.Contains(err.Error(), "password") { + t.Fatalf("invalid endpoint: %v", err) + } + } + for _, cfg := range []ClientConfig{{HandshakeTimeout: -1}, {ProxyMode: "bad"}, {ProxyMode: "direct", ProxyURL: "http://proxy"}, {Headers: http.Header{"Authorization": {"bad\r\nheader"}}}, {Headers: http.Header{"Sec-WebSocket-Key": {"fake"}}}, {RequireSubprotocol: true}, {Subprotocols: []string{"bad protocol"}}} { + if _, err := NewClient(cfg); err == nil { + t.Fatal("accepted invalid configuration") + } + } + for i := 0; i < 100; i++ { + d := retryBackoff(ReconnectConfig{MinBackoff: time.Second, MaxBackoff: 30 * time.Second}, i) + if d <= 0 || d > 30*time.Second { + t.Fatalf("unbounded delay %v", d) + } + } +} + +func TestClientElapsedBudgetInterruptsStalledHandshake(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) + defer server.Close() + cfg := clientTestConfig() + cfg.Reconnect = ReconnectConfig{Enabled: true, MaxElapsed: 50 * time.Millisecond} + c := clientForTest(t, cfg) + started := time.Now() + err := c.Run(context.Background(), wsURL(server), Handlers{}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("budget error: %v", err) + } + if time.Since(started) > time.Second { + t.Fatal("handshake exceeded reconnect budget") + } +} + +func TestClientSingleSlotRapidReconnect(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + code := 1012 + if calls.Add(1) >= 20 { + code = 1000 + } + _ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(code, ""), time.Now().Add(time.Second)) + _, _, _ = conn.ReadMessage() + })) + defer server.Close() + cfg := clientTestConfig() + cfg.MaxConnections = 1 + cfg.Reconnect = ReconnectConfig{Enabled: true, MinBackoff: time.Nanosecond, MaxBackoff: time.Nanosecond, MaxAttempts: 25} + c := clientForTest(t, cfg) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := c.Run(ctx, wsURL(server), Handlers{}); err != nil { + t.Fatalf("rapid reconnect: %v", err) + } + if calls.Load() != 20 { + t.Fatalf("connections: %d", calls.Load()) + } +} + +func TestClientSubprotocol(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{Subprotocols: []string{"omni.v1"}}).Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + for { + if _, _, err = conn.ReadMessage(); err != nil { + return + } + } + })) + defer server.Close() + cfg := clientTestConfig() + cfg.Subprotocols = []string{"omni.v1"} + cfg.RequireSubprotocol = true + c := clientForTest(t, cfg) + s, err := c.Dial(context.Background(), wsURL(server), Handlers{}) + if err != nil { + t.Fatal(err) + } + if s.Subprotocol() != "omni.v1" { + t.Fatal("wrong protocol") + } + _ = s.Close(1000, "") + cfg.Subprotocols = []string{"missing"} + other := clientForTest(t, cfg) + if _, err = other.Dial(context.Background(), wsURL(server), Handlers{}); err == nil { + t.Fatal("required protocol not enforced") + } +} + +func TestClientHTTPProxyAuthenticationAndTunnel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Proxy-Authorization") != "" { + t.Error("proxy credential leaked to target") + } + echoWebSocket(w, r) + })) + defer server.Close() + var connected atomic.Int32 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect || r.Header.Get("Proxy-Authorization") != "Basic dXNlcjpwYXNz" { + w.WriteHeader(http.StatusProxyAuthRequired) + return + } + upstream, err := net.Dial("tcp", r.Host) + if err != nil { + t.Error(err) + w.WriteHeader(502) + return + } + conn, rw, err := w.(http.Hijacker).Hijack() + if err != nil { + upstream.Close() + t.Error(err) + return + } + connected.Add(1) + _, _ = conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + go func() { defer conn.Close(); defer upstream.Close(); _, _ = io.Copy(upstream, rw) }() + go func() { defer conn.Close(); defer upstream.Close(); _, _ = io.Copy(conn, upstream) }() + })) + defer proxy.Close() + cfg := clientTestConfig() + cfg.ProxyMode = "url" + cfg.ProxyURL = strings.Replace(proxy.URL, "http://", "http://user:pass@", 1) + c := clientForTest(t, cfg) + messages := make(chan Message, 1) + s, err := c.Dial(context.Background(), wsURL(server), Handlers{OnMessage: func(_ context.Context, _ *Session, m Message) error { messages <- m; return nil }}) + if err != nil { + t.Fatal(err) + } + if err = s.Send(context.Background(), Message{Type: Text, Data: []byte("proxied")}); err != nil { + t.Fatal(err) + } + if string(receiveClientMessage(t, messages).Data) != "proxied" || connected.Load() != 1 { + t.Fatal("proxy not used") + } + _ = s.Close(1000, "") +} + +func BenchmarkClientRoundTrip(b *testing.B) { + server := httptest.NewServer(http.HandlerFunc(echoWebSocket)) + defer server.Close() + c, err := NewClient(clientTestConfig()) + if err != nil { + b.Fatal(err) + } + defer c.Shutdown(context.Background()) + received := make(chan struct{}, 1) + s, err := c.Dial(context.Background(), wsURL(server), Handlers{OnMessage: func(context.Context, *Session, Message) error { received <- struct{}{}; return nil }}) + if err != nil { + b.Fatal(err) + } + message := Message{Type: Text, Data: []byte("benchmark")} + b.ReportAllocs() + b.SetBytes(int64(len(message.Data))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := s.Send(context.Background(), message); err != nil { + b.Fatal(err) + } + <-received + } + b.StopTimer() + _ = s.Close(1000, "") +} diff --git a/ws/config.go b/ws/config.go new file mode 100644 index 0000000..18ada1c --- /dev/null +++ b/ws/config.go @@ -0,0 +1,83 @@ +package ws + +import ( + "fmt" + "time" +) + +// SessionConfig bounds connection memory and I/O. Zero fields use defaults; +// DisableHeartbeat explicitly disables automatic ping/pong deadlines. +type SessionConfig struct { + MaxMessageBytes int64 `json:"maxMessageBytes"` + ReadQueueSize int `json:"readQueueSize"` + ReadQueueBytes int64 `json:"readQueueBytes"` + WriteQueueSize int `json:"writeQueueSize"` + WriteQueueBytes int64 `json:"writeQueueBytes"` + WriteTimeout time.Duration `json:"writeTimeout"` + PingInterval time.Duration `json:"pingInterval"` + PongTimeout time.Duration `json:"pongTimeout"` + CloseTimeout time.Duration `json:"closeTimeout"` + DisableHeartbeat bool `json:"disableHeartbeat"` +} + +func DefaultSessionConfig() SessionConfig { + return SessionConfig{MaxMessageBytes: 1 << 20, ReadQueueSize: 64, ReadQueueBytes: 4 << 20, + WriteQueueSize: 64, WriteQueueBytes: 4 << 20, WriteTimeout: 10 * time.Second, + PingInterval: 30 * time.Second, PongTimeout: 60 * time.Second, CloseTimeout: 5 * time.Second} +} + +func (c SessionConfig) defaults() SessionConfig { + d := DefaultSessionConfig() + if c.MaxMessageBytes == 0 { + c.MaxMessageBytes = d.MaxMessageBytes + } + if c.ReadQueueSize == 0 { + c.ReadQueueSize = d.ReadQueueSize + } + if c.ReadQueueBytes == 0 { + c.ReadQueueBytes = d.ReadQueueBytes + } + if c.WriteQueueSize == 0 { + c.WriteQueueSize = d.WriteQueueSize + } + if c.WriteQueueBytes == 0 { + c.WriteQueueBytes = d.WriteQueueBytes + } + if c.WriteTimeout == 0 { + c.WriteTimeout = d.WriteTimeout + } + if c.PingInterval == 0 { + c.PingInterval = d.PingInterval + } + if c.PongTimeout == 0 { + c.PongTimeout = d.PongTimeout + } + if c.CloseTimeout == 0 { + c.CloseTimeout = d.CloseTimeout + } + return c +} + +func (c SessionConfig) Validate() error { + if c.MaxMessageBytes < 0 || c.MaxMessageBytes > 1<<30 || c.ReadQueueSize < 0 || c.ReadQueueSize > 65536 || c.ReadQueueBytes < 0 || c.ReadQueueBytes > 1<<30 || c.WriteQueueSize < 0 || c.WriteQueueSize > 65536 || c.WriteQueueBytes < 0 || c.WriteQueueBytes > 1<<30 { + return fmt.Errorf("ws: message and queue limits must be nonnegative and bounded") + } + if c.WriteTimeout < 0 || c.PingInterval < 0 || c.PongTimeout < 0 || c.CloseTimeout < 0 { + return fmt.Errorf("ws: timeouts must be nonnegative") + } + if c.WriteTimeout > 24*time.Hour || c.PingInterval > 24*time.Hour || c.PongTimeout > 24*time.Hour || c.CloseTimeout > 24*time.Hour { + return fmt.Errorf("ws: timeout exceeds 24 hours") + } + c = c.defaults() + if !c.DisableHeartbeat && c.PongTimeout <= c.PingInterval { + return fmt.Errorf("ws: pongTimeout must exceed pingInterval") + } + return nil +} + +func normalizeSessionConfig(c SessionConfig) (SessionConfig, error) { + if err := c.Validate(); err != nil { + return SessionConfig{}, err + } + return c.defaults(), nil +} diff --git a/ws/errors.go b/ws/errors.go new file mode 100644 index 0000000..2a4c624 --- /dev/null +++ b/ws/errors.go @@ -0,0 +1,48 @@ +package ws + +import ( + "errors" + "fmt" +) + +var ( + ErrClosed = errors.New("ws: session closed") + ErrNotConnected = errors.New("ws: not connected") + ErrQueueFull = errors.New("ws: queue full") + ErrMessageTooLarge = errors.New("ws: message too large") + ErrUnsupportedTransport = errors.New("ws: transport does not support websocket upgrade") + ErrServerClosed = errors.New("ws: server closed") + ErrInvalidMessage = errors.New("ws: invalid message") +) + +// CloseError deliberately excludes the peer-provided reason, which is untrusted +// and may contain credentials. Cause remains available through errors.Unwrap. +type CloseError struct { + Code int + Cause error +} + +func (e *CloseError) Error() string { return fmt.Sprintf("ws: connection closed (code %d)", e.Code) } +func (e *CloseError) Unwrap() error { return e.Cause } + +// HandshakeError exposes the HTTP status without putting a URL or response body +// in the error text. Inspect Cause explicitly only when it is safe to do so. +type HandshakeError struct { + StatusCode int + Cause error +} + +func (e *HandshakeError) Error() string { + return fmt.Sprintf("ws: handshake failed (HTTP %d)", e.StatusCode) +} +func (e *HandshakeError) Unwrap() error { return e.Cause } + +// OpError preserves the underlying cause without including its potentially +// sensitive text in logs. Op is a fixed operation name, never a URL or payload. +type OpError struct { + Op string + Cause error +} + +func (e *OpError) Error() string { return "ws: " + e.Op + " failed" } +func (e *OpError) Unwrap() error { return e.Cause } diff --git a/ws/hub/hub.go b/ws/hub/hub.go new file mode 100644 index 0000000..355ba31 --- /dev/null +++ b/ws/hub/hub.go @@ -0,0 +1,319 @@ +// Package hub provides a bounded, process-local WebSocket registry. Connection +// and group IDs are opaque application keys; callers authorize membership. +// It does not persist messages or provide cross-process delivery. +package hub + +import ( + "errors" + "reflect" + "strings" + "sync" + + "git.yuntuo.hk/k2safe/go-omni/ws" +) + +var ( + ErrClosed = errors.New("ws/hub: closed") + ErrNotFound = errors.New("ws/hub: connection not found") + ErrDuplicate = errors.New("ws/hub: duplicate connection ID") + ErrLimit = errors.New("ws/hub: resource limit reached") + ErrInvalid = errors.New("ws/hub: invalid connection or group") +) + +// Connection is implemented by *ws.Session. Implementations must be safe for +// concurrent use, have a stable ID, and make TrySend nonblocking with bounded +// memory. Done closes only after connection work finishes. +type Connection interface { + ID() string + Done() <-chan struct{} + TrySend(ws.Message) error + Close(int, string) error +} + +type Config struct { + MaxConnections int `json:"maxConnections"` + MaxGroups int `json:"maxGroups"` + MaxMemberships int `json:"maxMemberships"` + MaxGroupsPerConnection int `json:"maxGroupsPerConnection"` +} + +func (cfg Config) Validate() error { + if cfg.MaxConnections < 0 || cfg.MaxConnections > 1000000 || cfg.MaxGroups < 0 || cfg.MaxGroups > 1000000 || cfg.MaxMemberships < 0 || cfg.MaxMemberships > 10000000 || cfg.MaxGroupsPerConnection < 0 || cfg.MaxGroupsPerConnection > 10000 { + return errors.New("ws/hub: invalid limits") + } + return nil +} + +type entry struct { + connection Connection + groups map[string]struct{} + removed chan struct{} +} +type Hub struct { + mu sync.RWMutex + config Config + connections map[string]*entry + groups map[string]map[string]*entry + memberships int + closed bool + done chan struct{} + watchers sync.WaitGroup +} + +func New(cfg Config) (*Hub, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + if cfg.MaxConnections == 0 { + cfg.MaxConnections = 10000 + } + if cfg.MaxGroups == 0 { + cfg.MaxGroups = 1024 + } + if cfg.MaxMemberships == 0 { + cfg.MaxMemberships = 100000 + } + if cfg.MaxGroupsPerConnection == 0 { + cfg.MaxGroupsPerConnection = 32 + } + return &Hub{config: cfg, connections: make(map[string]*entry), groups: make(map[string]map[string]*entry), done: make(chan struct{})}, nil +} + +func validKey(key string) bool { + return key != "" && len(key) <= 256 && !strings.ContainsAny(key, "\r\n\x00") +} + +// Add borrows a connection and starts one bounded watcher to remove it when +// Done closes. Remove and Close stop those watchers without closing connections. +func (h *Hub) Add(connection Connection) error { + if connection == nil { + return ErrInvalid + } + v := reflect.ValueOf(connection) + if (v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface || v.Kind() == reflect.Map || v.Kind() == reflect.Func || v.Kind() == reflect.Slice || v.Kind() == reflect.Chan) && v.IsNil() { + return ErrInvalid + } + id := connection.ID() + done := connection.Done() + if !validKey(id) || done == nil { + return ErrInvalid + } + select { + case <-done: + return ws.ErrClosed + default: + } + h.mu.Lock() + if h.closed { + h.mu.Unlock() + return ErrClosed + } + if _, ok := h.connections[id]; ok { + h.mu.Unlock() + return ErrDuplicate + } + if len(h.connections) >= h.config.MaxConnections { + h.mu.Unlock() + return ErrLimit + } + e := &entry{connection: connection, groups: make(map[string]struct{}), removed: make(chan struct{})} + h.connections[id] = e + h.watchers.Add(1) + h.mu.Unlock() + go func() { + defer h.watchers.Done() + select { + case <-done: + h.mu.Lock() + if h.connections[id] == e { + h.removeLocked(id, e) + } + h.mu.Unlock() + case <-e.removed: + case <-h.done: + } + }() + return nil +} +func (h *Hub) removeLocked(id string, e *entry) { + delete(h.connections, id) + close(e.removed) + for name := range e.groups { + delete(h.groups[name], id) + h.memberships-- + if len(h.groups[name]) == 0 { + delete(h.groups, name) + } + } +} +func (h *Hub) Remove(id string) bool { + h.mu.Lock() + defer h.mu.Unlock() + e := h.connections[id] + if e == nil { + return false + } + h.removeLocked(id, e) + return true +} + +func (h *Hub) Join(id, group string) error { + if !validKey(group) { + return ErrInvalid + } + h.mu.Lock() + defer h.mu.Unlock() + if h.closed { + return ErrClosed + } + e := h.connections[id] + if e == nil { + return ErrNotFound + } + if _, ok := e.groups[group]; ok { + return nil + } + if len(e.groups) >= h.config.MaxGroupsPerConnection || h.memberships >= h.config.MaxMemberships { + return ErrLimit + } + if h.groups[group] == nil { + if len(h.groups) >= h.config.MaxGroups { + return ErrLimit + } + h.groups[group] = make(map[string]*entry) + } + h.groups[group][id] = e + e.groups[group] = struct{}{} + h.memberships++ + return nil +} +func (h *Hub) Leave(id, group string) bool { + h.mu.Lock() + defer h.mu.Unlock() + e := h.connections[id] + if e == nil { + return false + } + if _, ok := e.groups[group]; !ok { + return false + } + delete(e.groups, group) + delete(h.groups[group], id) + h.memberships-- + if len(h.groups[group]) == 0 { + delete(h.groups, group) + } + return true +} + +// Result reports queue acceptance, never remote receipt or business success. +// Errors contains one entry per rejected destination; no messages are silently +// dropped. Concurrent removal may race a snapshot and yield a closed error. +type Result struct { + Attempted int + Queued int + Errors map[string]error +} + +func send(entries map[string]*entry, message ws.Message) Result { + result := Result{Attempted: len(entries)} + for id, e := range entries { + if err := e.connection.TrySend(message); err != nil { + if result.Errors == nil { + result.Errors = make(map[string]error) + } + result.Errors[id] = err + } else { + result.Queued++ + } + } + return result +} +func (h *Hub) Send(id string, message ws.Message) (Result, error) { + h.mu.RLock() + if h.closed { + h.mu.RUnlock() + return Result{}, ErrClosed + } + e := h.connections[id] + h.mu.RUnlock() + if e == nil { + return Result{}, ErrNotFound + } + return send(map[string]*entry{id: e}, message), nil +} +func (h *Hub) snapshot(group string) (map[string]*entry, error) { + h.mu.RLock() + defer h.mu.RUnlock() + if h.closed { + return nil, ErrClosed + } + source := h.connections + if group != "" { + source = h.groups[group] + } + out := make(map[string]*entry, len(source)) + for id, e := range source { + out[id] = e + } + return out, nil +} +func (h *Hub) Broadcast(message ws.Message) (Result, error) { + entries, err := h.snapshot("") + if err != nil { + return Result{}, err + } + return send(entries, message), nil +} +func (h *Hub) SendGroup(group string, message ws.Message) (Result, error) { + if !validKey(group) { + return Result{}, ErrInvalid + } + entries, err := h.snapshot(group) + if err != nil { + return Result{}, err + } + return send(entries, message), nil +} +func (h *Hub) Kick(id string, code int, reason string) error { + h.mu.RLock() + if h.closed { + h.mu.RUnlock() + return ErrClosed + } + e := h.connections[id] + h.mu.RUnlock() + if e == nil { + return ErrNotFound + } + return e.connection.Close(code, reason) +} + +type Stats struct { + Connections int + Groups int + Memberships int +} + +func (h *Hub) Stats() Stats { + h.mu.RLock() + defer h.mu.RUnlock() + return Stats{len(h.connections), len(h.groups), h.memberships} +} + +// Close unregisters borrowed connections, releases membership memory and waits +// for internal watchers only. Use Session/Server shutdown to close connections. +func (h *Hub) Close() error { + h.mu.Lock() + if !h.closed { + h.closed = true + close(h.done) + for id, e := range h.connections { + h.removeLocked(id, e) + } + h.groups = make(map[string]map[string]*entry) + } + h.mu.Unlock() + h.watchers.Wait() + return nil +} diff --git a/ws/hub/hub_test.go b/ws/hub/hub_test.go new file mode 100644 index 0000000..65113ff --- /dev/null +++ b/ws/hub/hub_test.go @@ -0,0 +1,271 @@ +package hub + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "git.yuntuo.hk/k2safe/go-omni/ws" + "github.com/gorilla/websocket" +) + +type testConnection struct { + id string + done chan struct{} + once sync.Once + full bool + sent atomic.Int64 +} + +func (c *testConnection) ID() string { return c.id } +func (c *testConnection) Done() <-chan struct{} { return c.done } +func (c *testConnection) TrySend(ws.Message) error { + select { + case <-c.done: + return ws.ErrClosed + default: + } + if c.full { + return ws.ErrQueueFull + } + c.sent.Add(1) + return nil +} +func (c *testConnection) Close(int, string) error { c.once.Do(func() { close(c.done) }); return nil } +func newConnection(id string) *testConnection { + return &testConnection{id: id, done: make(chan struct{})} +} +func testHub(t *testing.T, cfg Config) *Hub { + t.Helper() + h, err := New(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = h.Close() }) + return h +} + +func TestHubRoutingBackpressureAndCleanup(t *testing.T) { + h := testHub(t, Config{}) + a, b, c := newConnection("a"), newConnection("b"), newConnection("c") + b.full = true + for _, connection := range []*testConnection{a, b, c} { + if err := h.Add(connection); err != nil { + t.Fatal(err) + } + } + for _, id := range []string{"a", "b"} { + if err := h.Join(id, "group"); err != nil { + t.Fatal(err) + } + } + message := ws.Message{Type: ws.Text, Data: []byte("hello")} + result, err := h.SendGroup("group", message) + if err != nil || result.Attempted != 2 || result.Queued != 1 || !errors.Is(result.Errors["b"], ws.ErrQueueFull) { + t.Fatalf("group: %#v %v", result, err) + } + if c.sent.Load() != 0 { + t.Fatal("group leaked") + } + result, err = h.Broadcast(message) + if err != nil || result.Attempted != 3 || result.Queued != 2 { + t.Fatalf("broadcast: %#v %v", result, err) + } + if err = h.Kick("a", 1000, ""); err != nil { + t.Fatal(err) + } + deadline := time.After(time.Second) + for h.Stats().Connections != 2 { + select { + case <-deadline: + t.Fatal("auto cleanup timeout") + default: + time.Sleep(time.Millisecond) + } + } + if got := h.Stats(); got.Memberships != 1 || got.Groups != 1 { + t.Fatalf("stats: %#v", got) + } + if !h.Remove("b") { + t.Fatal("remove") + } + if got := h.Stats(); got.Memberships != 0 || got.Groups != 0 { + t.Fatalf("group leak: %#v", got) + } + if _, err = h.Send("missing", message); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + _ = h.Close() + if _, err = h.Broadcast(message); !errors.Is(err, ErrClosed) { + t.Fatal(err) + } + select { + case <-c.done: + t.Fatal("hub closed borrowed connection") + default: + } +} + +func TestHubLimitsAndReusedID(t *testing.T) { + h := testHub(t, Config{MaxConnections: 2, MaxGroups: 1, MaxMemberships: 1, MaxGroupsPerConnection: 1}) + first := newConnection("a") + if err := h.Add(first); err != nil { + t.Fatal(err) + } + if err := h.Add(first); !errors.Is(err, ErrDuplicate) { + t.Fatal(err) + } + if err := h.Add(newConnection("b")); err != nil { + t.Fatal(err) + } + if err := h.Add(newConnection("c")); !errors.Is(err, ErrLimit) { + t.Fatal(err) + } + if err := h.Join("a", "one"); err != nil { + t.Fatal(err) + } + if err := h.Join("a", "one"); err != nil { + t.Fatal("idempotent join", err) + } + for _, pair := range [][2]string{{"a", "two"}, {"b", "one"}} { + if err := h.Join(pair[0], pair[1]); !errors.Is(err, ErrLimit) { + t.Fatal(err) + } + } + h.Remove("a") + second := newConnection("a") + if err := h.Add(second); err != nil { + t.Fatal(err) + } + _ = first.Close(1000, "") + if err := h.Join("a", "replacement"); err != nil { + t.Fatal(err) + } + if _, err := h.Send("a", ws.Message{Type: ws.Text}); err != nil { + t.Fatal("old watcher removed replacement", err) + } + var nilConnection *testConnection + if err := h.Add(nilConnection); !errors.Is(err, ErrInvalid) { + t.Fatal(err) + } +} + +func TestHubConcurrentLifecycle(t *testing.T) { + h := testHub(t, Config{MaxConnections: 100}) + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + c := newConnection(fmt.Sprint(i)) + if err := h.Add(c); err != nil { + t.Error(err) + return + } + for j := 0; j < 10; j++ { + _ = h.Join(c.ID(), "shared") + _, _ = h.Broadcast(ws.Message{Type: ws.Text}) + h.Leave(c.ID(), "shared") + } + _ = c.Close(1000, "") + }(i) + } + wg.Wait() + _ = h.Close() + if h.Stats() != (Stats{}) { + t.Fatal("registry not released") + } +} + +func TestHubRealSessionDeliveryAndDisconnect(t *testing.T) { + received := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + for { + _, data, err := conn.ReadMessage() + if err != nil { + return + } + received <- string(data) + } + })) + defer server.Close() + client, err := ws.NewClient(ws.ClientConfig{ProxyMode: "direct", Session: ws.SessionConfig{CloseTimeout: 100 * time.Millisecond}}) + if err != nil { + t.Fatal(err) + } + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := client.Shutdown(ctx); err != nil { + t.Error(err) + } + }() + s, err := client.Dial(context.Background(), "ws"+strings.TrimPrefix(server.URL, "http"), ws.Handlers{}) + if err != nil { + t.Fatal(err) + } + h := testHub(t, Config{}) + if err = h.Add(s); err != nil { + t.Fatal(err) + } + if err = h.Join(s.ID(), "room"); err != nil { + t.Fatal(err) + } + result, err := h.SendGroup("room", ws.Message{Type: ws.Text, Data: []byte("queued")}) + if err != nil || result.Queued != 1 { + t.Fatalf("send: %#v %v", result, err) + } + select { + case got := <-received: + if got != "queued" { + t.Fatal(got) + } + case <-time.After(time.Second): + t.Fatal("delivery timed out") + } + _ = s.Close(1000, "") + deadline := time.After(time.Second) + for h.Stats().Connections != 0 { + select { + case <-deadline: + t.Fatal("disconnected Session still registered") + default: + time.Sleep(time.Millisecond) + } + } +} + +func BenchmarkHubBroadcast(b *testing.B) { + for _, count := range []int{1, 100, 1000} { + b.Run(fmt.Sprint(count), func(b *testing.B) { + h, _ := New(Config{}) + defer h.Close() + for i := 0; i < count; i++ { + if err := h.Add(newConnection(fmt.Sprint(i))); err != nil { + b.Fatal(err) + } + } + message := ws.Message{Type: ws.Text, Data: []byte("benchmark")} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + result, err := h.Broadcast(message) + if err != nil || result.Queued != count { + b.Fatal("broadcast failed") + } + } + }) + } +} diff --git a/ws/limits_test.go b/ws/limits_test.go new file mode 100644 index 0000000..a5b0b22 --- /dev/null +++ b/ws/limits_test.go @@ -0,0 +1,100 @@ +package ws + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func TestSessionConfigUpperBounds(t *testing.T) { + for _, c := range []SessionConfig{{MaxMessageBytes: 1<<30 + 1}, {ReadQueueSize: 65537}, {WriteQueueSize: 65537}, {ReadQueueBytes: 1<<30 + 1}, {WriteQueueBytes: 1<<30 + 1}, {CloseTimeout: 25 * time.Hour}} { + if c.Validate() == nil { + t.Fatal("oversized config accepted") + } + } +} + +func TestClientReconnectsRemoteGoingAway(t *testing.T) { + var handshakes atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + if handshakes.Add(1) == 1 { + _ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(1001, ""), time.Now().Add(time.Second)) + } + for { + if _, _, err = conn.ReadMessage(); err != nil { + return + } + } + })) + defer server.Close() + client, err := NewClient(ClientConfig{ProxyMode: "direct", Reconnect: ReconnectConfig{Enabled: true, MinBackoff: time.Millisecond, MaxBackoff: time.Millisecond}}) + if err != nil { + t.Fatal(err) + } + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := client.Shutdown(ctx); err != nil { + t.Error(err) + } + }() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + connected := make(chan struct{}, 1) + done := make(chan error, 1) + go func() { + done <- client.Run(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), Handlers{OnOpen: func(context.Context, *Session) error { + if handshakes.Load() >= 2 { + connected <- struct{}{} + } + return nil + }}) + }() + select { + case <-connected: + case err := <-done: + t.Fatalf("Run ended before reconnect: %v", err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + _ = client.Close() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if stats := client.Stats(); stats.DialAttempts != 2 || stats.Reconnects != 1 || stats.HandshakeFailures != 0 { + t.Fatal(stats) + } +} + +func TestClientHandshakeStats(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "denied", http.StatusUnauthorized) })) + defer server.Close() + client, err := NewClient(ClientConfig{ProxyMode: "direct"}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + _, err = client.Dial(context.Background(), "ws"+strings.TrimPrefix(server.URL, "http"), Handlers{}) + if err == nil { + t.Fatal("expected rejection") + } + if stats := client.Stats(); stats.DialAttempts != 1 || stats.HandshakeFailures != 1 || stats.Reconnects != 0 { + t.Fatal(stats) + } +} diff --git a/ws/observe/README.md b/ws/observe/README.md new file mode 100644 index 0000000..8686124 --- /dev/null +++ b/ws/observe/README.md @@ -0,0 +1,44 @@ +# WebSocket 可选观测 + +`ws/observe` 为 `ws.Client`、`ws.Server` 的会话提供连接日志和聚合快照。核心 `ws` 不依赖全局 Logger;观测器不启动后台 goroutine,不给每条消息增加日志、锁或 span。 + +```go +logger, err := logx.New(logx.Default()) +if err != nil { return err } +defer logger.Close() // 必须晚于 Client/Server 的 Shutdown 完成 + +observer := observe.New(logger) // nil 表示只统计,不记日志 +handlers := observer.Wrap(ws.Handlers{ + OnMessage: func(ctx context.Context, session *ws.Session, message ws.Message) error { + return session.Send(ctx, message) + }, +}) +server, err := ws.NewServer(ws.ServerConfig{}) +if err != nil { return err } +mux.Handle("/ws", server.Handler(handlers)) + +// 由项目按自己的采样周期读取,接入 metrics、Prometheus 或 OTel。 +snapshot := observer.Snapshot() +``` + +导入路径为 `git.yuntuo.hk/k2safe/go-omni/ws/observe`。同一个 Observer 和同一组包装后的 Handlers 可供多个连接并发复用;每个会话有独立的开始时间。每个会话仅包装一次。使用客户端重连和 `ClientConfig.Prepare` 时,在每次 Prepare 返回前调用 `observer.Wrap(handlers)`。 + +快照包含: + +- `Opened / Closed / Active`:已观察到的连接总数、关闭回调结束数、当前处于受观察生命周期中的连接数。连接可能在 OnOpen 执行前断开,此时仍在 OnClose 计入 Opened 和 Closed,日志时长为 0。 +- `MessagesSent / MessagesReceived / BytesSent / BytesReceived`:已关闭会话的最终统计,加上当前会话的即时统计。发送成功表示写入完成;不是对端收到消息或业务处理成功的确认。统计口径与 `Session.Stats()` 一致。 +- `WriteQueuedMessages / WriteQueuedBytes / ReadQueuedMessages / ReadQueuedBytes`:调用快照时,各活跃连接队列水位之和。接收侧包括仍在处理、尚未释放的消息。 +- `SampledPeak*`:历次 Snapshot 观察到的聚合峰值;不是精确的历史峰值,采样间隔内的突增可能未被记录。 +- `CloseCallbackPanics`:下游 OnClose 回调发生 panic 的次数。 + +Snapshot 为 O(活跃会话数),只在采样时读取 `Session.Stats()`,不会替换消息回调。避免在每条消息上采样;采样周期由项目根据连接规模决定。快照不会保存已关闭连接的 ID,也不引入高基数指标标签。自行导出 OTel 时,建议记录握手、连接时长、消息数与字节数;业务消息追踪应显式传递上下文,不自动延长握手 span 到整条连接的寿命。 + +日志只由适配器写入固定事件名、Session ID、时长、关闭码和固定阶段。不会展开错误文本、Cause、URL、请求头、消息体或对端关闭原因;业务自定义 `OpError.Op` 也通过固定白名单筛选。用户传入 Logger 自身已经附加的属性仍由用户负责。默认日志级别为 Info。 + +OnClose 的收尾使用 defer,即使业务回调 panic,也会归档消息统计、移除活跃记录、记录固定的 `on_close_panic` 阶段,再把 panic 交还核心处理。此处“关闭”表示 I/O 已停止且下游 OnClose 已返回或 panic;核心随后才关闭 `Session.Done()`。因此 `Snapshot.Active == 0` 不是生命周期完成屏障,停机必须等待 `Client/Server.Shutdown` 或 `Session.Done`,再关闭 Logger。观测器不会在 OnClose 中等待自身 Done,也不会在 Done 后继续写日志。 + +运行真实 TCP 与并发测试: + +```sh +go test -race ./ws/observe +``` diff --git a/ws/observe/observe.go b/ws/observe/observe.go new file mode 100644 index 0000000..a7ed94d --- /dev/null +++ b/ws/observe/observe.go @@ -0,0 +1,194 @@ +// Package observe adds opt-in, payload-free WebSocket lifecycle logs and +// aggregate snapshots. The ws core has no logger dependency. Observers do not +// own loggers, start goroutines, or create per-message tracing spans. +package observe + +import ( + "context" + "errors" + "sync" + "time" + + "git.yuntuo.hk/k2safe/go-omni/logx" + "git.yuntuo.hk/k2safe/go-omni/ws" +) + +// Snapshot contains process-local totals for sessions observed by this Observer. +// Active covers observed callback lifetimes: removal happens after the user's +// OnClose returns, immediately before Session.Done. It is not a drain barrier. +// Queue peaks are sampled only when Snapshot is called, not lifetime maxima. +type Snapshot struct { + Opened uint64 `json:"opened"` + Closed uint64 `json:"closed"` + Active int `json:"active"` + CloseCallbackPanics uint64 `json:"closeCallbackPanics"` + MessagesSent uint64 `json:"messagesSent"` + MessagesReceived uint64 `json:"messagesReceived"` + BytesSent uint64 `json:"bytesSent"` + BytesReceived uint64 `json:"bytesReceived"` + WriteQueuedMessages int `json:"writeQueuedMessages"` + WriteQueuedBytes int64 `json:"writeQueuedBytes"` + ReadQueuedMessages int `json:"readQueuedMessages"` + ReadQueuedBytes int64 `json:"readQueuedBytes"` + SampledPeakWriteQueuedMessages int `json:"sampledPeakWriteQueuedMessages"` + SampledPeakWriteQueuedBytes int64 `json:"sampledPeakWriteQueuedBytes"` + SampledPeakReadQueuedMessages int `json:"sampledPeakReadQueuedMessages"` + SampledPeakReadQueuedBytes int64 `json:"sampledPeakReadQueuedBytes"` +} + +type observation struct{ started time.Time } + +type Observer struct { + log *logx.Logger + mu sync.Mutex + active map[*ws.Session]observation + totals Snapshot +} + +// New borrows log without changing or closing it. nil disables logs and retains +// metrics. Keep the logger open until Client/Server shutdown completes. +func New(log *logx.Logger) *Observer { + return &Observer{log: log, active: make(map[*ws.Session]observation)} +} + +// Wrap can be reused across concurrent sessions. It leaves OnMessage unchanged; +// message accounting comes from Session.Stats during snapshots and closure. +// Apply it once per session. With ClientConfig.Prepare, wrap each returned set +// of handlers so each reconnect uses the same observer and its own start time. +func (o *Observer) Wrap(next ws.Handlers) ws.Handlers { + if o == nil { + return next + } + return ws.Handlers{ + OnOpen: func(ctx context.Context, s *ws.Session) error { + o.opened(s) + if next.OnOpen != nil { + return next.OnOpen(ctx, s) + } + return nil + }, + OnMessage: next.OnMessage, + OnClose: func(s *ws.Session, err error) { + defer func() { + p := recover() + o.closed(s, err, p != nil) + if p != nil { + panic(p) + } + }() + if next.OnClose != nil { + next.OnClose(s, err) + } + }, + } +} + +func (o *Observer) opened(s *ws.Session) { + if s == nil { + return + } + o.mu.Lock() + _, exists := o.active[s] + if !exists { + o.active[s] = observation{started: time.Now()} + o.totals.Opened++ + } + o.mu.Unlock() + if !exists && o.log != nil { + o.log.Info("ws.open", "sessionId", s.ID(), "phase", "open") + } +} + +func (o *Observer) closed(s *ws.Session, err error, panicked bool) { + if s == nil { + return + } + stats := s.Stats() + o.mu.Lock() + observed, exists := o.active[s] + delete(o.active, s) + // A connection can close before its dispatch loop reaches OnOpen. Count + // that observed physical connection too, with zero measured duration. + if !exists { + o.totals.Opened++ + } + o.totals.Closed++ + if panicked { + o.totals.CloseCallbackPanics++ + } + addMessages(&o.totals, stats) + o.mu.Unlock() + duration := time.Duration(0) + if !observed.started.IsZero() { + duration = time.Since(observed.started) + } + if o.log != nil { + code, phase := closeFields(err, panicked) + o.log.Info("ws.close", "sessionId", s.ID(), "durationMs", duration.Seconds()*1000, "closeCode", code, "phase", phase) + } +} + +func closeFields(err error, panicked bool) (int, string) { + if panicked { + return 0, "on_close_panic" + } + if err == nil { + return 1000, "close" + } + var closed *ws.CloseError + if errors.As(err, &closed) { + return closed.Code, "close" + } + var op *ws.OpError + if errors.As(err, &op) { + // OpError is exported. Never write an arbitrary user-created Op to logs. + switch op.Op { + case "read", "write", "on_open", "on_message", "on_close", "ping", "pong", "close", "encode": + return 0, op.Op + } + } + if errors.Is(err, context.Canceled) { + return 0, "canceled" + } + if errors.Is(err, context.DeadlineExceeded) { + return 0, "timeout" + } + return 0, "error" +} + +func addMessages(out *Snapshot, stats ws.SessionStats) { + out.MessagesSent += stats.MessagesSent + out.MessagesReceived += stats.MessagesReceived + out.BytesSent += stats.BytesSent + out.BytesReceived += stats.BytesReceived +} + +// Snapshot samples live Session counters and adds immutable closed totals. It +// costs O(active sessions), acquires no locks on the message path, and keeps no +// per-connection records after OnClose. Message totals count I/O, not peer ACKs. +func (o *Observer) Snapshot() Snapshot { + if o == nil { + return Snapshot{} + } + o.mu.Lock() + defer o.mu.Unlock() + out := o.totals + out.Active = len(o.active) + for session := range o.active { + stats := session.Stats() + addMessages(&out, stats) + out.WriteQueuedMessages += stats.QueuedMessages + out.WriteQueuedBytes += stats.QueuedBytes + out.ReadQueuedMessages += stats.ReadQueuedMessages + out.ReadQueuedBytes += stats.ReadQueuedBytes + } + o.totals.SampledPeakWriteQueuedMessages = max(o.totals.SampledPeakWriteQueuedMessages, out.WriteQueuedMessages) + o.totals.SampledPeakWriteQueuedBytes = max(o.totals.SampledPeakWriteQueuedBytes, out.WriteQueuedBytes) + o.totals.SampledPeakReadQueuedMessages = max(o.totals.SampledPeakReadQueuedMessages, out.ReadQueuedMessages) + o.totals.SampledPeakReadQueuedBytes = max(o.totals.SampledPeakReadQueuedBytes, out.ReadQueuedBytes) + out.SampledPeakWriteQueuedMessages = o.totals.SampledPeakWriteQueuedMessages + out.SampledPeakWriteQueuedBytes = o.totals.SampledPeakWriteQueuedBytes + out.SampledPeakReadQueuedMessages = o.totals.SampledPeakReadQueuedMessages + out.SampledPeakReadQueuedBytes = o.totals.SampledPeakReadQueuedBytes + return out +} diff --git a/ws/observe/observe_test.go b/ws/observe/observe_test.go new file mode 100644 index 0000000..fda5665 --- /dev/null +++ b/ws/observe/observe_test.go @@ -0,0 +1,300 @@ +package observe + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "git.yuntuo.hk/k2safe/go-omni/logx" + "git.yuntuo.hk/k2safe/go-omni/ws" + "github.com/gorilla/websocket" +) + +func echo(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + for { + kind, data, err := conn.ReadMessage() + if err != nil { + return + } + if err = conn.WriteMessage(kind, data); err != nil { + return + } + } +} +func testClient(t *testing.T) *ws.Client { + t.Helper() + c, err := ws.NewClient(ws.ClientConfig{ProxyMode: "direct", Session: ws.SessionConfig{CloseTimeout: 50 * time.Millisecond}}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := c.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + return c +} +func endpoint(s *httptest.Server) string { return "ws" + strings.TrimPrefix(s.URL, "http") } +func wait(t *testing.T, ch <-chan struct{}) { + t.Helper() + select { + case <-ch: + case <-time.After(2 * time.Second): + t.Fatal("timeout") + } +} + +func TestObserverConcurrentSessionsAndTotals(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(echo)) + defer server.Close() + client := testClient(t) + o := New(nil) + const connections = 8 + const count = 10 + var workers sync.WaitGroup + stopSamples := make(chan struct{}) + samplesDone := make(chan struct{}) + go func() { + defer close(samplesDone) + for { + select { + case <-stopSamples: + return + default: + _ = o.Snapshot() + } + } + }() + for i := 0; i < connections; i++ { + workers.Add(1) + go func() { + defer workers.Done() + opened := make(chan struct{}) + received := make(chan struct{}, count) + s, err := client.Dial(context.Background(), endpoint(server), o.Wrap(ws.Handlers{OnOpen: func(context.Context, *ws.Session) error { close(opened); return nil }, OnMessage: func(context.Context, *ws.Session, ws.Message) error { received <- struct{}{}; return nil }})) + if err != nil { + t.Error(err) + return + } + wait(t, opened) + for j := 0; j < count; j++ { + if err = s.Send(context.Background(), ws.Message{Type: ws.Text, Data: []byte("test")}); err != nil { + t.Error(err) + return + } + wait(t, received) + } + _ = s.Close(1000, "") + wait(t, s.Done()) + }() + } + workers.Wait() + close(stopSamples) + wait(t, samplesDone) + got := o.Snapshot() + if got.Opened != connections || got.Closed != connections || got.Active != 0 || got.MessagesSent != connections*count || got.MessagesReceived != connections*count || got.BytesSent != 4*connections*count || got.BytesReceived != 4*connections*count { + t.Fatalf("snapshot: %+v", got) + } + if again := o.Snapshot(); again != got { + t.Fatalf("closed totals changed: %+v -> %+v", got, again) + } +} + +func TestObserverCloseCallbackBoundaryAndPanic(t *testing.T) { + for _, panics := range []bool{false, true} { + t.Run(fmt.Sprint(panics), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(echo)) + defer server.Close() + client := testClient(t) + o := New(nil) + opened := make(chan struct{}) + closing := make(chan struct{}) + release := make(chan struct{}) + s, err := client.Dial(context.Background(), endpoint(server), o.Wrap(ws.Handlers{OnOpen: func(context.Context, *ws.Session) error { close(opened); return nil }, OnClose: func(*ws.Session, error) { + close(closing) + <-release + if panics { + panic("SECRET CALLBACK PANIC") + } + }})) + if err != nil { + t.Fatal(err) + } + wait(t, opened) + _ = s.Close(1000, "") + wait(t, closing) + if got := o.Snapshot(); got.Active != 1 || got.Closed != 0 { + t.Fatalf("closed before callback finished: %+v", got) + } + select { + case <-s.Done(): + t.Fatal("Done closed before user callback") + default: + } + close(release) + wait(t, s.Done()) + got := o.Snapshot() + if got.Active != 0 || got.Closed != 1 { + t.Fatalf("callback did not release observation: %+v", got) + } + if panics && got.CloseCallbackPanics != 1 { + t.Fatalf("panic not counted: %+v", got) + } + }) + } +} + +func TestObserverPayloadFreeLogsAndIndependentDurations(t *testing.T) { + var output bytes.Buffer + logger, err := logx.New(logx.Config{Writer: &output, Format: "json", Level: "info"}) + if err != nil { + t.Fatal(err) + } + defer logger.Close() + o := New(logger) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + if _, _, err = conn.ReadMessage(); err == nil { + _ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(1008, "SECRET CLOSE REASON"), time.Now().Add(time.Second)) + _, _, _ = conn.ReadMessage() + } + })) + defer server.Close() + client := testClient(t) + var sessions []*ws.Session + for i := 0; i < 2; i++ { + opened := make(chan struct{}) + s, err := client.Dial(context.Background(), endpoint(server)+"/?token=SECRET-URL", o.Wrap(ws.Handlers{OnOpen: func(context.Context, *ws.Session) error { close(opened); return nil }})) + if err != nil { + t.Fatal(err) + } + wait(t, opened) + sessions = append(sessions, s) + if i == 0 { + time.Sleep(40 * time.Millisecond) + } + } + for _, s := range sessions { + if err = s.Send(context.Background(), ws.Message{Type: ws.Text, Data: []byte("SECRET MESSAGE")}); err != nil { + t.Fatal(err) + } + wait(t, s.Done()) + } + if strings.Contains(output.String(), "SECRET") { + t.Fatalf("log leaked source data: %s", output.String()) + } + var durations []float64 + for _, line := range strings.Split(strings.TrimSpace(output.String()), "\n") { + var row map[string]any + if err = json.Unmarshal([]byte(line), &row); err != nil { + t.Fatal(err) + } + for key := range row { + switch key { + case "time", "level", "msg", "sessionId", "phase", "durationMs", "closeCode": + default: + t.Fatalf("unexpected log field %q", key) + } + } + if row["msg"] == "ws.close" { + if row["closeCode"] != float64(1008) { + t.Fatalf("close code: %#v", row) + } + durations = append(durations, row["durationMs"].(float64)) + } + } + if len(durations) != 2 || durations[0] < 30 { + t.Fatalf("timers not per session: %v", durations) + } +} + +func TestObserverQueueSnapshotsAndSampledPeaks(t *testing.T) { + startSending := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + <-startSending + for i := 0; i < 5; i++ { + if conn.WriteMessage(websocket.TextMessage, []byte("queue")) != nil { + return + } + } + for { + if _, _, err = conn.ReadMessage(); err != nil { + return + } + } + })) + defer server.Close() + client := testClient(t) + o := New(nil) + opened := make(chan struct{}) + release := make(chan struct{}) + s, err := client.Dial(context.Background(), endpoint(server), o.Wrap(ws.Handlers{OnOpen: func(context.Context, *ws.Session) error { close(opened); return nil }, OnMessage: func(context.Context, *ws.Session, ws.Message) error { <-release; return nil }})) + if err != nil { + t.Fatal(err) + } + wait(t, opened) + close(startSending) + deadline := time.After(2 * time.Second) + var sampled Snapshot + for { + sampled = o.Snapshot() + if sampled.MessagesReceived == 5 { + break + } + select { + case <-deadline: + close(release) + t.Fatal("receive timeout") + default: + time.Sleep(time.Millisecond) + } + } + if sampled.ReadQueuedMessages != 5 || sampled.ReadQueuedBytes != 25 || sampled.SampledPeakReadQueuedMessages != 5 { + t.Fatalf("queue snapshot: %+v", sampled) + } + close(release) + _ = s.Close(1000, "") + wait(t, s.Done()) + final := o.Snapshot() + if final.ReadQueuedMessages != 0 || final.SampledPeakReadQueuedMessages != 5 { + t.Fatalf("final: %+v", final) + } +} + +type opaqueError struct{} + +func (opaqueError) Error() string { panic("must not expand error") } +func TestObserverCloseFieldsNeverExpandsErrors(t *testing.T) { + for _, err := range []error{opaqueError{}, &ws.OpError{Op: "SECRET STAGE", Cause: opaqueError{}}, &ws.CloseError{Code: 1008, Cause: opaqueError{}}} { + _, phase := closeFields(err, false) + if strings.Contains(phase, "SECRET") { + t.Fatal("untrusted phase") + } + } + if code, phase := closeFields(opaqueError{}, true); code != 0 || phase != "on_close_panic" { + t.Fatalf("panic fields: %d %s", code, phase) + } +} diff --git a/ws/omni/handler.go b/ws/omni/handler.go new file mode 100644 index 0000000..550d440 --- /dev/null +++ b/ws/omni/handler.go @@ -0,0 +1,44 @@ +// Package omni connects managed WebSocket sessions to a GoOmni application. +package omni + +import ( + "errors" + + "git.yuntuo.hk/k2safe/go-omni/app" + "git.yuntuo.hk/k2safe/go-omni/config" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/ws" +) + +// Handler preserves the HTTP middleware chain and records the actual upgrade +// status. The server owns all session callbacks after this handler returns. +// Register Server.Stop/Drain on App or svc Runtime before accepting connections. +func Handler(server *ws.Server, handlers ws.Handlers) httpx.Handler { + if server == nil { + panic("ws/omni: nil server") + } + return func(c *httpx.Context) error { + _, err := server.Accept(c.Raw(), c.Request(), handlers) + if err == nil { + c.MarkUpgraded() + } + // Accept has already written any handshake failure response. + return nil + } +} + +// Mount registers a GET endpoint and its shutdown barriers on a standalone App. +// Use Handler with svc.OnStop/OnDrain when the service Runtime owns dependencies. +// Mount once per Server. Configure the App before calling it. +func Mount(a *app.App, path string, server *ws.Server, handlers ws.Handlers) error { + if a == nil || server == nil { + return errors.New("ws/omni: app and server required") + } + if a.Config().Transport != config.TransportNetHTTP && a.Config().Transport != "" { + return ws.ErrUnsupportedTransport + } + a.GET(path, Handler(server, handlers)) + a.OnStop("ws:"+path, server.Stop) + a.OnDrain("ws:"+path, server.Drain) + return nil +} diff --git a/ws/omni/handler_test.go b/ws/omni/handler_test.go new file mode 100644 index 0000000..00bdda8 --- /dev/null +++ b/ws/omni/handler_test.go @@ -0,0 +1,92 @@ +package omni_test + +import ( + "context" + "errors" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + omni "git.yuntuo.hk/k2safe/go-omni" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/lifecycle" + "git.yuntuo.hk/k2safe/go-omni/ws" + wsomni "git.yuntuo.hk/k2safe/go-omni/ws/omni" + "github.com/gorilla/websocket" +) + +func TestMountLifetimeAndStatus(t *testing.T) { + a := omni.New(omni.WithLogger(false)) + var observed atomic.Int64 + a.Use(func(next httpx.Handler) httpx.Handler { + return func(c *httpx.Context) error { err := next(c); observed.Store(int64(c.Status())); return err } + }) + s, err := ws.NewServer(ws.ServerConfig{Session: ws.SessionConfig{CloseTimeout: 30 * time.Millisecond}}) + if err != nil { + t.Fatal(err) + } + if err = wsomni.Mount(a, "/ws", s, ws.Handlers{OnMessage: func(ctx context.Context, s *ws.Session, m ws.Message) error { return s.Send(ctx, m) }}); err != nil { + t.Fatal(err) + } + h := httptest.NewServer(a) + defer h.Close() + defer a.Close() + c, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(h.URL, "http")+"/ws", nil) + if err != nil { + t.Fatal(err) + } + defer c.Close() + for i := 0; i < 10; i++ { + if err = c.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil { + t.Fatal(err) + } + c.SetReadDeadline(time.Now().Add(time.Second)) + _, v, e := c.ReadMessage() + if e != nil || string(v) != "hello" { + t.Fatal(e) + } + } + if observed.Load() != 101 { + t.Fatal("upgrade logged as", observed.Load()) + } + var cleanup atomic.Bool + a.OnShutdown(func() error { + if s.Stats().ActiveConnections != 0 { + t.Error("resource closed before sessions") + } + cleanup.Store(true) + return nil + }) + if err = a.Close(); err != nil { + t.Fatal(err) + } + if !cleanup.Load() { + t.Fatal("missing cleanup") + } +} + +func TestAppRetainsResourcesForBlockedComponent(t *testing.T) { + a := omni.New(omni.WithLogger(false)) + closed := false + a.OnStop("blocked", func(context.Context) error { return nil }) + a.OnDrain("blocked", func(context.Context) error { return context.DeadlineExceeded }) + a.OnShutdown(func() error { closed = true; return nil }) + if err := a.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) { + t.Fatal(err) + } + if closed { + t.Fatal("unsafe cleanup") + } +} + +func TestMountRejectsNative(t *testing.T) { + a := omni.New(omni.WithTransport(omni.TransportNative)) + defer a.Close() + s, _ := ws.NewServer(ws.ServerConfig{}) + defer s.Shutdown(context.Background()) + if err := wsomni.Mount(a, "/ws", s, ws.Handlers{}); !errors.Is(err, ws.ErrUnsupportedTransport) { + t.Fatal(err) + } +} diff --git a/ws/rpc/README.md b/ws/rpc/README.md new file mode 100644 index 0000000..89b1d77 --- /dev/null +++ b/ws/rpc/README.md @@ -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` 测协议解析与严格输入校验。 diff --git a/ws/rpc/config.go b/ws/rpc/config.go new file mode 100644 index 0000000..cf3d2db --- /dev/null +++ b/ws/rpc/config.go @@ -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 } diff --git a/ws/rpc/peer.go b/ws/rpc/peer.go new file mode 100644 index 0000000..6032a27 --- /dev/null +++ b/ws/rpc/peer.go @@ -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() + } +} diff --git a/ws/rpc/peer_test.go b/ws/rpc/peer_test.go new file mode 100644 index 0000000..9a86cdd --- /dev/null +++ b/ws/rpc/peer_test.go @@ -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() +} diff --git a/ws/rpc/protocol.go b/ws/rpc/protocol.go new file mode 100644 index 0000000..9790d12 --- /dev/null +++ b/ws/rpc/protocol.go @@ -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 +} diff --git a/ws/rpc/subscriptions.go b/ws/rpc/subscriptions.go new file mode 100644 index 0000000..eb14447 --- /dev/null +++ b/ws/rpc/subscriptions.go @@ -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 +} diff --git a/ws/server.go b/ws/server.go new file mode 100644 index 0000000..02efcd4 --- /dev/null +++ b/ws/server.go @@ -0,0 +1,405 @@ +package ws + +import ( + "context" + "errors" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// ServerConfig is independent of HTTP body limits and HTTP request deadlines. +// Context owns accepted sessions; it must outlive HTTP handlers. Prepare runs +// before upgrading and may authenticate or create per-connection handlers. +type ServerConfig struct { + Session SessionConfig `json:"session"` + MaxConnections int `json:"maxConnections"` + HandshakeTimeout time.Duration `json:"handshakeTimeout"` + AllowedOrigins []string `json:"allowedOrigins"` + RequireOrigin bool `json:"requireOrigin"` + Subprotocols []string `json:"subprotocols"` + RequireSubprotocol bool `json:"requireSubprotocol"` + EnableCompression bool `json:"enableCompression"` + HandshakeRate float64 `json:"handshakeRate"` // Per second; zero disables rate limiting. + HandshakeBurst int `json:"handshakeBurst"` + Context context.Context `json:"-"` + Prepare func(context.Context, *http.Request) (Handlers, error) `json:"-"` +} + +func DefaultServerConfig() ServerConfig { + return ServerConfig{Session: DefaultSessionConfig(), MaxConnections: 4096, HandshakeTimeout: 10 * time.Second} +} + +func (cfg ServerConfig) Validate() error { _, err := normalizeServerConfig(cfg); return err } + +func normalizeServerConfig(cfg ServerConfig) (ServerConfig, error) { + var err error + cfg.Session, err = normalizeSessionConfig(cfg.Session) + if err != nil { + return cfg, err + } + if cfg.MaxConnections < 0 || cfg.MaxConnections > 1<<20 || cfg.HandshakeTimeout < 0 || cfg.HandshakeRate < 0 || cfg.HandshakeRate > 1e9 || cfg.HandshakeRate != cfg.HandshakeRate || cfg.HandshakeBurst < 0 || cfg.HandshakeBurst > 1<<20 { + return cfg, errors.New("ws: invalid server limits") + } + if cfg.MaxConnections == 0 { + cfg.MaxConnections = 4096 + } + if cfg.HandshakeTimeout == 0 { + cfg.HandshakeTimeout = 10 * time.Second + } + if cfg.HandshakeRate > 0 && cfg.HandshakeBurst == 0 { + cfg.HandshakeBurst = 1 + } + if cfg.HandshakeRate == 0 && cfg.HandshakeBurst != 0 { + return cfg, errors.New("ws: handshake burst requires a rate") + } + if len(cfg.AllowedOrigins) > 1024 { + return cfg, errors.New("ws: too many allowed origins") + } + cfg.AllowedOrigins = append([]string(nil), cfg.AllowedOrigins...) + for i, v := range cfg.AllowedOrigins { + o, ok := canonicalOrigin(v) + if !ok { + return cfg, errors.New("ws: invalid allowed origin") + } + cfg.AllowedOrigins[i] = o + } + if len(cfg.Subprotocols) > 64 { + return cfg, errors.New("ws: too many subprotocols") + } + cfg.Subprotocols = append([]string(nil), cfg.Subprotocols...) + seen := make(map[string]bool) + for _, v := range cfg.Subprotocols { + if !protocolToken(v) || seen[v] { + return cfg, errors.New("ws: invalid or duplicate subprotocol") + } + seen[v] = true + } + if cfg.RequireSubprotocol && len(cfg.Subprotocols) == 0 { + return cfg, errors.New("ws: required subprotocol list is empty") + } + if cfg.Context == nil { + cfg.Context = context.Background() + } + return cfg, nil +} + +func protocolToken(s string) bool { + if len(s) == 0 || len(s) > 256 { + return false + } + for _, r := range s { + if r <= 32 || r >= 127 || strings.ContainsRune("()<>@,;:\\\"/[]?={}", r) { + return false + } + } + return true +} + +func canonicalOrigin(raw string) (string, bool) { + if len(raw) > 4096 || strings.ContainsAny(raw, " \t\r\n,\\?#") { + return "", false + } + u, err := url.Parse(raw) + if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.Opaque != "" || strings.Contains(u.Host, "*") || strings.HasSuffix(u.Host, ":") { + return "", false + } + host := strings.ToLower(u.Host) + // net/url accepts some unbracketed colon hosts. An Origin must contain a + // serialized DNS host or a bracketed, valid IPv6 address, never that ambiguity. + name := u.Hostname() + if strings.Contains(name, ":") { + if !strings.HasPrefix(u.Host, "[") || net.ParseIP(name) == nil { + return "", false + } + } else { + if strings.ContainsAny(u.Host, "[]") { + return "", false + } + for _, r := range name { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '-' || r == '_') { + return "", false + } + } + } + if port := u.Port(); port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return "", false + } + } + if u.Scheme == "http" && u.Port() == "80" { + host = strings.TrimSuffix(host, ":80") + } + if u.Scheme == "https" && u.Port() == "443" { + host = strings.TrimSuffix(host, ":443") + } + return u.Scheme + "://" + host, true +} + +// RejectError lets Prepare select a public HTTP 4xx status. Error details are +// deliberately never written into the handshake response. +type RejectError struct{ StatusCode int } + +func (e *RejectError) Error() string { return "ws: handshake rejected" } + +type ServerStats struct { + ActiveConnections, Handshakes int + Accepted, Rejected uint64 +} + +// Server owns all upgraded sessions, including their callbacks. Use Stop and +// Drain with lifecycle.Runtime, or Shutdown before releasing shared resources. +type Server struct { + cfg ServerConfig + mu sync.Mutex + stopped bool + active int // includes reserved handshakes; protected by the same admission lock + handshakes int + sessions map[string]*Session + done chan struct{} + doneClosed bool + accepted, rejected uint64 + tokens float64 + refill time.Time + stopParent func() bool +} + +func NewServer(cfg ServerConfig) (*Server, error) { + cfg, err := normalizeServerConfig(cfg) + if err != nil { + return nil, err + } + s := &Server{cfg: cfg, sessions: make(map[string]*Session), done: make(chan struct{}), tokens: float64(cfg.HandshakeBurst), refill: time.Now()} + s.stopParent = context.AfterFunc(cfg.Context, func() { _ = s.Stop(context.Background()) }) + return s, nil +} + +func (s *Server) Handler(h Handlers) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = s.Accept(w, r, h) }) +} +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { _, _ = s.Accept(w, r, Handlers{}) } + +func (s *Server) originAllowed(r *http.Request) bool { + values := r.Header.Values("Origin") + if len(values) == 0 { + return !s.cfg.RequireOrigin + } + if len(values) != 1 { + return false + } + origin, ok := canonicalOrigin(values[0]) + if !ok { + return false + } + if len(s.cfg.AllowedOrigins) > 0 { + for _, v := range s.cfg.AllowedOrigins { + if v == origin { + return true + } + } + return false + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + self, ok := canonicalOrigin(scheme + "://" + r.Host) + return ok && origin == self +} + +func (s *Server) admit() int { + s.mu.Lock() + defer s.mu.Unlock() + if s.stopped || s.cfg.Context.Err() != nil { + s.rejected++ + return http.StatusServiceUnavailable + } + if s.active >= s.cfg.MaxConnections { + s.rejected++ + return http.StatusServiceUnavailable + } + if s.cfg.HandshakeRate > 0 { + now := time.Now() + s.tokens = min(float64(s.cfg.HandshakeBurst), s.tokens+now.Sub(s.refill).Seconds()*s.cfg.HandshakeRate) + s.refill = now + if s.tokens < 1 { + s.rejected++ + return http.StatusTooManyRequests + } + s.tokens-- + } + s.active++ + s.handshakes++ + return 0 +} + +func (s *Server) finishLocked() { + if s.stopped && s.active == 0 && !s.doneClosed { + s.doneClosed = true + close(s.done) + } +} + +// Accept writes the handshake response (including failures). It returns after +// transferring the connection to Server; callers must not reuse the writer or +// retain request-scoped state in callbacks. Session uses ServerConfig.Context. +func (s *Server) Accept(w http.ResponseWriter, r *http.Request, h Handlers) (session *Session, retErr error) { + if status := s.admit(); status != 0 { + http.Error(w, http.StatusText(status), status) + return nil, &HandshakeError{StatusCode: status} + } + transferred := false + defer func() { + if !transferred { + s.mu.Lock() + s.active-- + s.handshakes-- + s.rejected++ + s.finishLocked() + s.mu.Unlock() + } + }() + fail := func(status int, cause error) (*Session, error) { + http.Error(w, http.StatusText(status), status) + return nil, &HandshakeError{StatusCode: status, Cause: cause} + } + if r.Method != http.MethodGet || !websocket.IsWebSocketUpgrade(r) { + return fail(http.StatusBadRequest, nil) + } + if !s.originAllowed(r) { + return fail(http.StatusForbidden, nil) + } + if _, ok := w.(http.Hijacker); !ok { + return fail(http.StatusNotImplemented, ErrUnsupportedTransport) + } + if s.cfg.RequireSubprotocol { + matched := false + for _, offered := range websocket.Subprotocols(r) { + for _, supported := range s.cfg.Subprotocols { + if offered == supported { + matched = true + } + } + } + if !matched { + return fail(http.StatusBadRequest, nil) + } + } + ctx, cancel := context.WithTimeout(r.Context(), s.cfg.HandshakeTimeout) + defer cancel() + if s.cfg.Prepare != nil { + var err error + h, err = prepareSafely(s.cfg.Prepare, ctx, r) + if err != nil { + status := http.StatusUnauthorized + var reject *RejectError + if errors.As(err, &reject) && reject != nil && reject.StatusCode >= 400 && reject.StatusCode < 500 { + status = reject.StatusCode + } + return fail(status, nil) + } + } + if ctx.Err() != nil { + return fail(http.StatusRequestTimeout, ctx.Err()) + } + deadline, _ := ctx.Deadline() + remaining := time.Until(deadline) + if remaining <= 0 { + return fail(http.StatusRequestTimeout, context.DeadlineExceeded) + } + upgrader := websocket.Upgrader{HandshakeTimeout: remaining, Subprotocols: s.cfg.Subprotocols, EnableCompression: s.cfg.EnableCompression, CheckOrigin: func(*http.Request) bool { return true }, Error: func(w http.ResponseWriter, _ *http.Request, status int, _ error) { + http.Error(w, http.StatusText(status), status) + }} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return nil, &HandshakeError{Cause: errors.New("upgrade failed")} + } + session = newSession(conn, s.cfg.Session, h, s.cfg.Context) + s.mu.Lock() + s.handshakes-- + s.sessions[session.ID()] = session + s.accepted++ + stopped := s.stopped + transferred = true + s.mu.Unlock() + session.start() + if stopped { + _ = session.Close(websocket.CloseGoingAway, "") + } + go func() { + <-session.Done() + s.mu.Lock() + delete(s.sessions, session.ID()) + s.active-- + s.finishLocked() + s.mu.Unlock() + }() + return session, nil +} + +func prepareSafely(fn func(context.Context, *http.Request) (Handlers, error), ctx context.Context, r *http.Request) (h Handlers, err error) { + defer func() { + if recover() != nil { + err = errors.New("ws: preparation failed") + } + }() + return fn(ctx, r) +} + +// Stop closes admission and begins bounded graceful closing of existing +// sessions. It never waits for callbacks and is safe to call from a callback. +func (s *Server) Stop(context.Context) error { + s.mu.Lock() + s.stopped = true + all := make([]*Session, 0, len(s.sessions)) + for _, v := range s.sessions { + all = append(all, v) + } + s.finishLocked() + s.mu.Unlock() + for _, v := range all { + _ = v.Close(websocket.CloseGoingAway, "") + } + return nil +} + +// Drain returns nil only when all handshakes, I/O and callbacks have exited. +// On timeout it aborts sockets, but does not claim blocked user callbacks ended. +func (s *Server) Drain(ctx context.Context) error { + _ = s.Stop(ctx) + select { + case <-s.done: + s.stopParent() + return nil + default: + } + select { + case <-s.done: + s.stopParent() + return nil + case <-ctx.Done(): + s.mu.Lock() + all := make([]*Session, 0, len(s.sessions)) + for _, v := range s.sessions { + all = append(all, v) + } + s.mu.Unlock() + for _, v := range all { + v.Abort() + } + return ctx.Err() + } +} +func (s *Server) Shutdown(ctx context.Context) error { return s.Drain(ctx) } +func (s *Server) Stats() ServerStats { + s.mu.Lock() + defer s.mu.Unlock() + return ServerStats{ActiveConnections: len(s.sessions), Handshakes: s.handshakes, Accepted: s.accepted, Rejected: s.rejected} +} diff --git a/ws/server_test.go b/ws/server_test.go new file mode 100644 index 0000000..4e46e33 --- /dev/null +++ b/ws/server_test.go @@ -0,0 +1,326 @@ +package ws + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func managedServer(t testing.TB, cfg ServerConfig, h Handlers) (*Server, *httptest.Server, string) { + t.Helper() + s, err := NewServer(cfg) + if err != nil { + t.Fatal(err) + } + httpServer := httptest.NewServer(s.Handler(h)) + t.Cleanup(func() { + httpServer.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := s.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + return s, httpServer, "ws" + strings.TrimPrefix(httpServer.URL, "http") +} + +func TestServerOriginAndProtocolPolicy(t *testing.T) { + _, host, endpoint := managedServer(t, ServerConfig{Subprotocols: []string{"test.v1"}, RequireSubprotocol: true}, Handlers{}) + for _, tt := range []struct { + name, origin string + code int + }{ + {"service", "", 101}, {"same", host.URL, 101}, {"foreign", "https://foreign.invalid", 403}, {"null", "null", 403}, {"path", host.URL + "/", 403}, {"comma", host.URL + ", https://evil.invalid", 403}, + } { + t.Run(tt.name, func(t *testing.T) { + d := websocket.Dialer{Subprotocols: []string{"test.v1"}} + h := http.Header{} + if tt.origin != "" { + h.Set("Origin", tt.origin) + } + c, r, err := d.Dial(endpoint, h) + if c != nil { + defer c.Close() + } + if r == nil || r.StatusCode != tt.code { + t.Fatalf("status=%v err=%v", r, err) + } + if tt.code == 101 && (err != nil || c.Subprotocol() != "test.v1") { + t.Fatal("negotiation failed", err) + } + }) + } + _, r, err := websocket.DefaultDialer.Dial(endpoint, nil) + if err == nil || r.StatusCode != 400 { + t.Fatal("missing subprotocol accepted") + } + d := websocket.Dialer{Subprotocols: []string{"test.v1"}} + _, r, err = d.Dial(endpoint, http.Header{"Origin": []string{host.URL, host.URL}}) + if err == nil || r.StatusCode != 403 { + t.Fatal("duplicate origin accepted") + } +} + +func TestServerAllowedOriginsAndAuthentication(t *testing.T) { + s, _, endpoint := managedServer(t, ServerConfig{AllowedOrigins: []string{"https://allowed.invalid"}, RequireOrigin: true, Prepare: func(ctx context.Context, r *http.Request) (Handlers, error) { + if r.Header.Get("Authorization") != "Bearer local-test" { + return Handlers{}, &RejectError{StatusCode: 403} + } + return Handlers{OnMessage: func(ctx context.Context, s *Session, m Message) error { return s.Send(ctx, m) }}, nil + }}, Handlers{}) + for _, h := range []http.Header{nil, {"Origin": []string{"https://allowed.invalid"}}, {"Authorization": []string{"Bearer local-test"}}, {"Origin": []string{"https://wrong.invalid"}, "Authorization": []string{"Bearer local-test"}}} { + c, r, e := websocket.DefaultDialer.Dial(endpoint, h) + if c != nil { + c.Close() + } + if e == nil || r.StatusCode != 403 { + t.Fatal("unauthorized handshake accepted") + } + } + c, _, err := websocket.DefaultDialer.Dial(endpoint, http.Header{"Origin": []string{"https://allowed.invalid"}, "Authorization": []string{"Bearer local-test"}}) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if err = c.WriteMessage(websocket.TextMessage, []byte("echo")); err != nil { + t.Fatal(err) + } + c.SetReadDeadline(time.Now().Add(time.Second)) + _, body, err := c.ReadMessage() + if err != nil || string(body) != "echo" { + t.Fatal(string(body), err) + } + if s.Stats().Accepted != 1 { + t.Fatal(s.Stats()) + } +} + +func TestServerStopDuringHandshake(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + s, _, endpoint := managedServer(t, ServerConfig{Prepare: func(context.Context, *http.Request) (Handlers, error) { + close(entered) + <-release + return Handlers{}, nil + }}, Handlers{}) + dialDone := make(chan struct{}) + go func() { + defer close(dialDone) + c, _, _ := websocket.DefaultDialer.Dial(endpoint, nil) + if c != nil { + defer c.Close() + c.ReadMessage() + } + }() + <-entered + _ = s.Stop(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + if err := s.Drain(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("handshake was not tracked", err) + } + close(release) + select { + case <-dialDone: + case <-time.After(time.Second): + t.Fatal("accepted connection escaped stop") + } + ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second) + defer cancel2() + if err := s.Drain(ctx2); err != nil { + t.Fatal(err) + } +} + +func TestServerLimitsAndShutdownCallbacks(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + s, _, endpoint := managedServer(t, ServerConfig{MaxConnections: 1, Session: SessionConfig{CloseTimeout: 20 * time.Millisecond}}, Handlers{OnMessage: func(context.Context, *Session, Message) error { close(entered); <-release; return nil }}) + c, _, err := websocket.DefaultDialer.Dial(endpoint, nil) + if err != nil { + t.Fatal(err) + } + defer c.Close() + _, r, err := websocket.DefaultDialer.Dial(endpoint, nil) + if err == nil || r.StatusCode != 503 { + t.Fatal("connection cap failed") + } + if err = c.WriteMessage(websocket.TextMessage, []byte("hold")); err != nil { + t.Fatal(err) + } + <-entered + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + if err = s.Shutdown(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("blocked callback incorrectly drained", err) + } + close(release) + ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second) + defer cancel2() + if err = s.Drain(ctx2); err != nil { + t.Fatal(err) + } + _, r, err = websocket.DefaultDialer.Dial(endpoint, nil) + if err == nil || r.StatusCode != 503 { + t.Fatal("server admitted after shutdown") + } +} + +func TestServerHandshakeRateAndPanic(t *testing.T) { + _, _, endpoint := managedServer(t, ServerConfig{HandshakeRate: 0.001, HandshakeBurst: 1, Prepare: func(context.Context, *http.Request) (Handlers, error) { panic("SECRET") }}, Handlers{}) + _, r, e := websocket.DefaultDialer.Dial(endpoint, nil) + if e == nil || r.StatusCode != 401 { + t.Fatal("prepare panic not contained", e) + } + _, r, e = websocket.DefaultDialer.Dial(endpoint, nil) + if e == nil || r.StatusCode != 429 { + t.Fatal("rate cap not enforced", e) + } +} + +func TestServerUnsupportedWriter(t *testing.T) { + s, err := NewServer(ServerConfig{}) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest("GET", "http://localhost/ws", nil) + r.Header.Set("Upgrade", "websocket") + r.Header.Set("Connection", "Upgrade") + w := httptest.NewRecorder() + _, err = s.Accept(w, r, Handlers{}) + if !errors.Is(err, ErrUnsupportedTransport) || w.Code != 501 { + t.Fatal(w.Code, err) + } + if err = s.Shutdown(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestServerConcurrentShutdown(t *testing.T) { + s, _, endpoint := managedServer(t, ServerConfig{Session: SessionConfig{CloseTimeout: 10 * time.Millisecond}}, Handlers{}) + var wg sync.WaitGroup + for i := 0; i < 40; i++ { + wg.Add(1) + go func() { + defer wg.Done() + c, _, _ := websocket.DefaultDialer.Dial(endpoint, nil) + if c != nil { + defer c.Close() + c.SetReadDeadline(time.Now().Add(time.Second)) + c.ReadMessage() + } + }() + } + _ = s.Stop(context.Background()) + wg.Wait() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := s.Drain(ctx); err != nil { + t.Fatal(err) + } + if stats := s.Stats(); stats.ActiveConnections != 0 || stats.Handshakes != 0 { + t.Fatal(stats) + } +} + +func TestServerConfigValidation(t *testing.T) { + for _, cfg := range []ServerConfig{{AllowedOrigins: []string{"*"}}, {AllowedOrigins: []string{"https://user:secret@host"}}, {AllowedOrigins: []string{"https://host/path"}}, {HandshakeRate: -1}, {HandshakeBurst: 1}, {RequireSubprotocol: true}, {Subprotocols: []string{"bad\r\n"}}, {MaxConnections: -1}} { + if err := cfg.Validate(); err == nil { + t.Fatal("bad config accepted") + } + } +} + +func TestServerParallelConnections(t *testing.T) { + s, _, endpoint := managedServer(t, ServerConfig{MaxConnections: 256}, Handlers{OnMessage: func(ctx context.Context, s *Session, m Message) error { return s.Send(ctx, m) }}) + var workers sync.WaitGroup + for i := 0; i < 128; i++ { + workers.Add(1) + go func(id int) { + defer workers.Done() + c, _, err := websocket.DefaultDialer.Dial(endpoint, nil) + if err != nil { + t.Error(err) + return + } + defer c.Close() + c.SetReadDeadline(time.Now().Add(15 * time.Second)) + c.SetWriteDeadline(time.Now().Add(15 * time.Second)) + for n := 0; n < 50; n++ { + value := fmt.Sprintf("%d:%d", id, n) + if err := c.WriteMessage(websocket.TextMessage, []byte(value)); err != nil { + t.Error(err) + return + } + _, got, err := c.ReadMessage() + if err != nil || string(got) != value { + t.Errorf("session isolation: %s %v", got, err) + return + } + } + }(i) + } + workers.Wait() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := s.Shutdown(ctx); err != nil { + t.Fatal(err) + } + if s.Stats().Accepted != 128 { + t.Fatal(s.Stats()) + } +} + +func TestServerTypedNilRejection(t *testing.T) { + _, _, endpoint := managedServer(t, ServerConfig{Prepare: func(context.Context, *http.Request) (Handlers, error) { var err *RejectError; return Handlers{}, err }}, Handlers{}) + _, r, err := websocket.DefaultDialer.Dial(endpoint, nil) + if err == nil || r == nil || r.StatusCode != 401 { + t.Fatal(r, err) + } +} + +func FuzzOrigin(f *testing.F) { + for _, v := range []string{"https://example.com", "http://localhost:8080", "null", "https://[::1]:443", "https://host:invalid", "https://evil@safe"} { + f.Add(v) + } + f.Fuzz(func(t *testing.T, v string) { + a, ok := canonicalOrigin(v) + if ok { + b, ok2 := canonicalOrigin(a) + if !ok2 || a != b { + t.Fatal("not canonical") + } + } + }) +} + +func BenchmarkServerRoundTrip(b *testing.B) { + for _, size := range []int{64, 1024, 64 << 10} { + b.Run(fmt.Sprint(size), func(b *testing.B) { + _, _, endpoint := managedServer(b, ServerConfig{}, Handlers{OnMessage: func(ctx context.Context, s *Session, m Message) error { return s.Send(ctx, m) }}) + c, _, err := websocket.DefaultDialer.Dial(endpoint, nil) + if err != nil { + b.Fatal(err) + } + defer c.Close() + payload := make([]byte, size) + b.SetBytes(int64(size) * 2) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err = c.WriteMessage(websocket.BinaryMessage, payload); err != nil { + b.Fatal(err) + } + if _, _, err = c.ReadMessage(); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/ws/session.go b/ws/session.go new file mode 100644 index 0000000..b2f2ce0 --- /dev/null +++ b/ws/session.go @@ -0,0 +1,568 @@ +// Package ws provides bounded, managed WebSocket connections. +package ws + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "sync" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/gorilla/websocket" +) + +type MessageType int + +const ( + Text MessageType = websocket.TextMessage + Binary MessageType = websocket.BinaryMessage +) + +type Message struct { + Type MessageType + Data []byte +} +type Handler func(context.Context, *Session, Message) error + +// Handlers execute serially for each session. OnClose runs after all I/O and +// message callbacks have stopped; it must not call Wait on its own session. +type Handlers struct { + OnOpen func(context.Context, *Session) error + OnMessage Handler + OnClose func(*Session, error) +} + +type SessionStats struct { + MessagesSent, MessagesReceived, BytesSent, BytesReceived uint64 + QueuedMessages int + QueuedBytes int64 + ReadQueuedMessages int + ReadQueuedBytes int64 +} + +type outgoing struct { + message Message + ctx context.Context + result chan error +} +type controlFrame struct { + kind int + data []byte +} + +var nextSessionID atomic.Uint64 + +// Session owns exactly one reader and one writer. Sends are ordered by queue +// admission. Message callbacks never run on the reader goroutine. +type Session struct { + conn *websocket.Conn + cfg SessionConfig + handlers Handlers + id string + parent context.Context + ctx context.Context + cancel context.CancelFunc + done, stopped, closingCh, peerClosed, closeSent chan struct{} + startOnce, stopOnce, peerOnce sync.Once + wg sync.WaitGroup + mu sync.Mutex + closing bool + closeCode int + closeReason string + err error + stats SessionStats + out chan outgoing + in chan Message + controls chan controlFrame +} + +// newSession requires a validated config. Ownership transfers before start so +// callers can register the session without racing completion. +func newSession(conn *websocket.Conn, cfg SessionConfig, handlers Handlers, parent context.Context) *Session { + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithCancel(parent) + cfg = cfg.defaults() + return &Session{conn: conn, cfg: cfg, handlers: handlers, id: "ws-" + strconv.FormatUint(nextSessionID.Add(1), 10), + parent: parent, ctx: ctx, cancel: cancel, done: make(chan struct{}), stopped: make(chan struct{}), + closingCh: make(chan struct{}), peerClosed: make(chan struct{}), closeSent: make(chan struct{}), + out: make(chan outgoing, cfg.WriteQueueSize), in: make(chan Message, cfg.ReadQueueSize), controls: make(chan controlFrame, 8)} +} + +func (s *Session) ID() string { return s.id } +func (s *Session) Context() context.Context { return s.ctx } +func (s *Session) Done() <-chan struct{} { return s.done } +func (s *Session) Err() error { s.mu.Lock(); defer s.mu.Unlock(); return s.err } +func (s *Session) Subprotocol() string { return s.conn.Subprotocol() } +func (s *Session) Stats() SessionStats { s.mu.Lock(); defer s.mu.Unlock(); return s.stats } + +func (s *Session) start() { + s.startOnce.Do(func() { + s.wg.Add(4) + go func() { defer s.wg.Done(); s.readLoop() }() + go func() { defer s.wg.Done(); s.writeLoop() }() + go func() { defer s.wg.Done(); s.dispatchLoop() }() + go func() { defer s.wg.Done(); s.monitor() }() + go func() { + s.wg.Wait() + for { + select { + case item := <-s.out: + s.complete(item, ErrClosed) + default: + goto drained + } + } + drained: + // Release any discarded inbound payloads before publishing completion. + for { + select { + case msg := <-s.in: + s.releaseRead(msg) + default: + goto notified + } + } + notified: + if s.handlers.OnClose != nil { + func() { + defer func() { + if p := recover(); p != nil { + s.recordError(&OpError{Op: "on_close", Cause: fmt.Errorf("callback panic: %v", p)}) + } + }() + s.handlers.OnClose(s, s.Err()) + }() + } + close(s.done) + }() + }) +} + +// Send waits for the actual write. A cancellation before writing skips the +// message. Cancellation during writing terminates the connection; delivery may +// already have occurred and the caller must not automatically replay it. +func (s *Session) Send(ctx context.Context, msg Message) error { + if ctx == nil { + ctx = context.Background() + } + item, err := s.enqueue(ctx, msg, true) + if err != nil { + return err + } + select { + case err := <-item.result: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +// TrySend only acknowledges queue admission, not delivery. The payload is copied. +func (s *Session) TrySend(msg Message) error { + _, err := s.enqueue(context.Background(), msg, false) + return err +} +func (s *Session) SendJSON(ctx context.Context, value any) error { + data, err := json.Marshal(value) + if err != nil { + return &OpError{Op: "encode", Cause: err} + } + return s.Send(ctx, Message{Type: Text, Data: data}) +} + +func (s *Session) enqueue(ctx context.Context, msg Message, wait bool) (outgoing, error) { + if err := ctx.Err(); err != nil { + return outgoing{}, err + } + if msg.Type != Text && msg.Type != Binary { + return outgoing{}, ErrInvalidMessage + } + if int64(len(msg.Data)) > s.cfg.MaxMessageBytes { + return outgoing{}, ErrMessageTooLarge + } + if msg.Type == Text && !utf8.Valid(msg.Data) { + return outgoing{}, ErrInvalidMessage + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closing { + return outgoing{}, ErrClosed + } + if s.stats.QueuedMessages >= s.cfg.WriteQueueSize || int64(len(msg.Data)) > s.cfg.WriteQueueBytes-s.stats.QueuedBytes { + return outgoing{}, ErrQueueFull + } + item := outgoing{message: Message{Type: msg.Type, Data: append([]byte(nil), msg.Data...)}, ctx: ctx} + if wait { + item.result = make(chan error, 1) + } + select { + case s.out <- item: + s.stats.QueuedMessages++ + s.stats.QueuedBytes += int64(len(msg.Data)) + return item, nil + default: + return outgoing{}, ErrQueueFull + } +} + +func validCloseCode(code int) bool { + switch code { + case 1000, 1001, 1002, 1003, 1007, 1008, 1009, 1010, 1011, 1012, 1013, 1014: + return true + } + return code >= 3000 && code <= 4999 +} + +// Close stops admission and begins a bounded graceful close, draining already +// admitted sends. It is idempotent and never waits for a callback to finish. +func (s *Session) Close(code int, reason string) error { + if !validCloseCode(code) || len(reason) > 123 || !utf8.ValidString(reason) { + return ErrInvalidMessage + } + s.beginClose(code, reason, nil) + return nil +} +func (s *Session) beginClose(code int, reason string, cause error) { + s.mu.Lock() + if cause != nil && s.err == nil { + s.err = cause + } + if !s.closing { + s.closing = true + s.closeCode = code + s.closeReason = reason + close(s.closingCh) + } + s.mu.Unlock() + s.cancel() +} +func (s *Session) recordError(err error) { + s.mu.Lock() + if s.err == nil { + s.err = err + } + s.mu.Unlock() +} +func (s *Session) terminate(err error) { + s.beginClose(1001, "", err) + s.stopOnce.Do(func() { close(s.stopped); _ = s.conn.Close() }) +} + +// Abort immediately closes the socket; Wait still waits for callbacks to return. +func (s *Session) Abort() { s.terminate(ErrClosed) } +func (s *Session) Wait(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + select { + case <-s.done: + return s.Err() + default: + } + select { + case <-s.done: + return s.Err() + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *Session) monitor() { + select { + case <-s.stopped: + return + case <-s.parent.Done(): + s.beginClose(1001, "", s.parent.Err()) + case <-s.closingCh: + } + timer := time.NewTimer(s.cfg.CloseTimeout) + defer timer.Stop() + select { + case <-s.stopped: + return + case <-timer.C: + s.terminate(&OpError{Op: "close", Cause: context.DeadlineExceeded}) + return + case <-s.closeSent: + } + select { + case <-s.stopped: + return + case <-timer.C: + s.terminate(&OpError{Op: "close", Cause: context.DeadlineExceeded}) + case <-s.peerClosed: + s.terminate(nil) + } +} + +func (s *Session) readLoop() { + s.conn.SetReadLimit(s.cfg.MaxMessageBytes) + if !s.cfg.DisableHeartbeat { + _ = s.conn.SetReadDeadline(time.Now().Add(s.cfg.PongTimeout)) + s.conn.SetPongHandler(func(string) error { return s.conn.SetReadDeadline(time.Now().Add(s.cfg.PongTimeout)) }) + } + s.conn.SetPingHandler(func(data string) error { + select { + case s.controls <- controlFrame{websocket.PongMessage, []byte(data)}: + return nil + default: + return ErrQueueFull + } + }) + s.conn.SetCloseHandler(func(code int, _ string) error { + var cause error + s.mu.Lock() + locallyClosing := s.closing + s.mu.Unlock() + // A remote going-away frame can signal a restarting service. Preserve + // its code for Client.Run's retry policy; our own close acknowledgement + // remains a normal local shutdown. + if code != 1000 && code != 1005 && (code != 1001 || !locallyClosing) { + cause = &CloseError{Code: code} + } + reply := code + if !validCloseCode(reply) { + reply = 1000 + } + s.beginClose(reply, "", cause) + s.peerOnce.Do(func() { close(s.peerClosed) }) + return &websocket.CloseError{Code: code} + }) + for { + kind, reader, err := s.conn.NextReader() + if err != nil { + select { + case <-s.peerClosed: + return + case <-s.stopped: + return + default: + } + var ce *websocket.CloseError + if errors.As(err, &ce) { + s.terminate(&CloseError{Code: ce.Code, Cause: err}) + } else if errors.Is(err, websocket.ErrReadLimit) { + s.terminate(ErrMessageTooLarge) + } else { + s.terminate(&OpError{Op: "read", Cause: err}) + } + return + } + data, err := io.ReadAll(io.LimitReader(reader, s.cfg.MaxMessageBytes+1)) + if err != nil { + if errors.Is(err, websocket.ErrReadLimit) { + s.beginClose(1009, "", ErrMessageTooLarge) + } else { + s.terminate(&OpError{Op: "read", Cause: err}) + } + return + } + if int64(len(data)) > s.cfg.MaxMessageBytes { + s.beginClose(1009, "", ErrMessageTooLarge) + return + } + if kind == websocket.TextMessage && !utf8.Valid(data) { + s.beginClose(1007, "", ErrInvalidMessage) + return + } + s.mu.Lock() + if s.closing { + s.mu.Unlock() + continue + } + if s.stats.ReadQueuedMessages >= s.cfg.ReadQueueSize || int64(len(data)) > s.cfg.ReadQueueBytes-s.stats.ReadQueuedBytes { + s.mu.Unlock() + s.beginClose(1008, "", ErrQueueFull) + return + } + msg := Message{Type: MessageType(kind), Data: data} + select { + case s.in <- msg: + s.stats.ReadQueuedMessages++ + s.stats.ReadQueuedBytes += int64(len(data)) + s.stats.MessagesReceived++ + s.stats.BytesReceived += uint64(len(data)) + s.mu.Unlock() + default: + s.mu.Unlock() + s.beginClose(1008, "", ErrQueueFull) + return + } + } +} + +func (s *Session) releaseRead(msg Message) { + s.mu.Lock() + s.stats.ReadQueuedMessages-- + s.stats.ReadQueuedBytes -= int64(len(msg.Data)) + s.mu.Unlock() +} +func (s *Session) callback(fn func() error) (err error) { + defer func() { + if p := recover(); p != nil { + err = fmt.Errorf("callback panic: %v", p) + } + }() + return fn() +} +func (s *Session) dispatchLoop() { + if s.ctx.Err() != nil { + return + } + if s.handlers.OnOpen != nil { + if err := s.callback(func() error { return s.handlers.OnOpen(s.ctx, s) }); err != nil { + s.beginClose(1011, "", &OpError{Op: "on_open", Cause: err}) + return + } + } + for { + select { + case <-s.ctx.Done(): + return + case msg := <-s.in: + if s.ctx.Err() != nil { + s.releaseRead(msg) + return + } + var err error + if s.handlers.OnMessage != nil { + err = s.callback(func() error { return s.handlers.OnMessage(s.ctx, s, msg) }) + } + s.releaseRead(msg) + if err != nil { + s.beginClose(1011, "", &OpError{Op: "on_message", Cause: err}) + return + } + } + } +} + +func (s *Session) complete(item outgoing, err error) { + s.mu.Lock() + s.stats.QueuedMessages-- + s.stats.QueuedBytes -= int64(len(item.message.Data)) + if err == nil { + s.stats.MessagesSent++ + s.stats.BytesSent += uint64(len(item.message.Data)) + } + s.mu.Unlock() + if item.result != nil { + item.result <- err + } +} +func (s *Session) write(item outgoing) error { + if err := item.ctx.Err(); err != nil { + s.complete(item, err) + return nil + } + deadline := time.Now().Add(s.cfg.WriteTimeout) + if d, ok := item.ctx.Deadline(); ok && d.Before(deadline) { + deadline = d + } + if err := s.conn.SetWriteDeadline(deadline); err != nil { + s.complete(item, &OpError{Op: "write", Cause: err}) + return err + } + // Only the writer touches Gorilla's deadline field. Cancellation interrupts + // the underlying socket directly; joining the callback prevents a late expiry + // from corrupting the following message's deadline. + canceled := make(chan struct{}) + stop := context.AfterFunc(item.ctx, func() { _ = s.conn.UnderlyingConn().SetWriteDeadline(time.Now()); close(canceled) }) + err := s.conn.WriteMessage(int(item.message.Type), item.message.Data) + if !stop() { + <-canceled + } + if err != nil { + if item.ctx.Err() != nil { + err = item.ctx.Err() + } else if d, ok := item.ctx.Deadline(); ok && !time.Now().Before(d) { + // The socket timer can win the race with context's timer. + err = context.DeadlineExceeded + } + wrapped := &OpError{Op: "write", Cause: err} + s.complete(item, wrapped) + return wrapped + } + s.complete(item, nil) + return nil +} +func (s *Session) writeControl(frame controlFrame) error { + return s.conn.WriteControl(frame.kind, frame.data, time.Now().Add(s.cfg.WriteTimeout)) +} +func (s *Session) writeLoop() { + var ticks <-chan time.Time + if !s.cfg.DisableHeartbeat { + ticker := time.NewTicker(s.cfg.PingInterval) + defer ticker.Stop() + ticks = ticker.C + } + for { + select { + case <-s.stopped: + return + case <-s.closingCh: + s.finishWrites() + return + default: + } + select { + case <-s.stopped: + return + case <-s.closingCh: + s.finishWrites() + return + case item := <-s.out: + if err := s.write(item); err != nil { + s.terminate(err) + return + } + case frame := <-s.controls: + if err := s.writeControl(frame); err != nil { + s.terminate(&OpError{Op: "control", Cause: err}) + return + } + case <-ticks: + if err := s.writeControl(controlFrame{kind: websocket.PingMessage}); err != nil { + s.terminate(&OpError{Op: "ping", Cause: err}) + return + } + } + } +} +func (s *Session) finishWrites() { + for { + select { + case <-s.stopped: + return + case item := <-s.out: + select { + case <-s.peerClosed: + s.complete(item, ErrClosed) + continue + default: + } + if err := s.write(item); err != nil { + s.terminate(err) + return + } + default: + goto closeFrame + } + } +closeFrame: + s.mu.Lock() + code, reason := s.closeCode, s.closeReason + s.mu.Unlock() + if err := s.writeControl(controlFrame{websocket.CloseMessage, websocket.FormatCloseMessage(code, reason)}); err != nil { + s.terminate(&OpError{Op: "close", Cause: err}) + return + } + close(s.closeSent) +} diff --git a/ws/session_test.go b/ws/session_test.go new file mode 100644 index 0000000..01dc711 --- /dev/null +++ b/ws/session_test.go @@ -0,0 +1,392 @@ +package ws + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func sessionPair(t testing.TB, cfg SessionConfig, h Handlers, start, compression bool) (*Session, *websocket.Conn) { + t.Helper() + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + accepted := make(chan *Session, 1) + up := websocket.Upgrader{EnableCompression: compression} + host := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := up.Upgrade(w, r, nil) + if err != nil { + return + } + s := newSession(conn, cfg, h, context.Background()) + accepted <- s + if start { + s.start() + } + })) + dialer := websocket.Dialer{EnableCompression: compression} + peer, _, err := dialer.Dial("ws"+strings.TrimPrefix(host.URL, "http"), nil) + if err != nil { + host.Close() + t.Fatal(err) + } + s := <-accepted + t.Cleanup(func() { + s.Abort() + s.start() + _ = peer.Close() + host.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = s.Wait(ctx) + }) + return s, peer +} + +func sessionTestConfig() SessionConfig { + return SessionConfig{DisableHeartbeat: true, CloseTimeout: 100 * time.Millisecond, WriteTimeout: time.Second} +} +func waitSession(t *testing.T, s *Session) error { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err := s.Wait(ctx) + if errors.Is(err, context.DeadlineExceeded) && ctx.Err() != nil { + t.Fatal("session did not finish") + } + return err +} +func peerClose(t *testing.T, peer *websocket.Conn) { + t.Helper() + _ = peer.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(1000, ""), time.Now().Add(time.Second)) + _ = peer.SetReadDeadline(time.Now().Add(time.Second)) + _, _, _ = peer.ReadMessage() +} + +func TestSessionEchoAndCompletion(t *testing.T) { + var opened, closed atomic.Bool + s, peer := sessionPair(t, sessionTestConfig(), Handlers{ + OnOpen: func(context.Context, *Session) error { opened.Store(true); return nil }, + OnMessage: func(ctx context.Context, s *Session, msg Message) error { + if !opened.Load() { + return errors.New("message before open") + } + return s.Send(ctx, msg) + }, + OnClose: func(*Session, error) { closed.Store(true) }, + }, true, false) + for _, kind := range []int{websocket.TextMessage, websocket.BinaryMessage} { + if err := peer.WriteMessage(kind, []byte("hello")); err != nil { + t.Fatal(err) + } + got, data, err := peer.ReadMessage() + if err != nil || got != kind || string(data) != "hello" { + t.Fatalf("echo: %d %q %v", got, data, err) + } + } + peerClose(t, peer) + if err := waitSession(t, s); err != nil { + t.Fatal(err) + } + if !closed.Load() { + t.Fatal("Done preceded OnClose") + } + stats := s.Stats() + if stats.MessagesReceived != 2 || stats.MessagesSent != 2 || stats.QueuedMessages != 0 || stats.ReadQueuedMessages != 0 { + t.Fatalf("stats: %+v", stats) + } +} + +func TestSessionQueueBoundsCopiesAndGracefulDrain(t *testing.T) { + cfg := sessionTestConfig() + cfg.WriteQueueSize = 2 + cfg.WriteQueueBytes = 4 + s, peer := sessionPair(t, cfg, Handlers{}, false, false) + data := []byte("ab") + if err := s.TrySend(Message{Type: Text, Data: data}); err != nil { + t.Fatal(err) + } + data[0] = 'z' + if err := s.TrySend(Message{Type: Text, Data: []byte("cd")}); err != nil { + t.Fatal(err) + } + if err := s.TrySend(Message{Type: Text, Data: []byte("e")}); !errors.Is(err, ErrQueueFull) { + t.Fatalf("overflow: %v", err) + } + if err := s.Close(1000, ""); err != nil { + t.Fatal(err) + } + if err := s.TrySend(Message{Type: Text}); !errors.Is(err, ErrClosed) { + t.Fatalf("closing send: %v", err) + } + s.start() + for _, want := range []string{"ab", "cd"} { + _, got, err := peer.ReadMessage() + if err != nil || string(got) != want { + t.Fatalf("drain: %q %v", got, err) + } + } + _, _, err := peer.ReadMessage() + var ce *websocket.CloseError + if !errors.As(err, &ce) || ce.Code != 1000 { + t.Fatalf("close: %v", err) + } + if err := waitSession(t, s); err != nil { + t.Fatal(err) + } +} + +func TestSessionCanceledQueueDoesNotCloseConnection(t *testing.T) { + s, peer := sessionPair(t, sessionTestConfig(), Handlers{}, false, false) + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { result <- s.Send(ctx, Message{Type: Text, Data: []byte("skip")}) }() + deadline := time.Now().Add(time.Second) + for s.Stats().QueuedMessages == 0 { + if time.Now().After(deadline) { + t.Fatal("send never admitted") + } + time.Sleep(time.Millisecond) + } + cancel() + if err := <-result; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if err := s.TrySend(Message{Type: Text, Data: []byte("next")}); err != nil { + t.Fatal(err) + } + s.start() + _, data, err := peer.ReadMessage() + if err != nil || string(data) != "next" { + t.Fatalf("got %q %v", data, err) + } + peerClose(t, peer) + if err := waitSession(t, s); err != nil { + t.Fatal(err) + } +} + +func TestSessionConcurrentSendAndJSON(t *testing.T) { + s, peer := sessionPair(t, sessionTestConfig(), Handlers{}, true, false) + const count = 24 + results := make(chan error, count) + for i := 0; i < count; i++ { + go func(i int) { results <- s.SendJSON(context.Background(), i) }(i) + } + seen := make(map[string]bool) + for i := 0; i < count; i++ { + _, data, err := peer.ReadMessage() + if err != nil { + t.Fatal(err) + } + if seen[string(data)] { + t.Fatal("duplicate write") + } + seen[string(data)] = true + } + for i := 0; i < count; i++ { + if err := <-results; err != nil { + t.Fatal(err) + } + } + peerClose(t, peer) + if err := waitSession(t, s); err != nil { + t.Fatal(err) + } +} + +func TestSessionBoundedDispatcherDoesNotBlockPing(t *testing.T) { + entered := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + s, peer := sessionPair(t, sessionTestConfig(), Handlers{OnMessage: func(ctx context.Context, _ *Session, _ Message) error { + once.Do(func() { close(entered) }) + select { + case <-release: + case <-ctx.Done(): + } + return nil + }}, true, false) + t.Cleanup(func() { close(release) }) + if err := peer.WriteMessage(websocket.TextMessage, []byte("block callback")); err != nil { + t.Fatal(err) + } + <-entered + pong := make(chan struct{}, 1) + peer.SetPongHandler(func(data string) error { + if data == "probe" { + pong <- struct{}{} + } + return nil + }) + readDone := make(chan struct{}) + go func() { defer close(readDone); _, _, _ = peer.ReadMessage() }() + if err := peer.WriteControl(websocket.PingMessage, []byte("probe"), time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + select { + case <-pong: + case <-time.After(time.Second): + t.Fatal("blocked callback starved control frames") + } + _ = s.Close(1000, "") + <-readDone + if err := waitSession(t, s); err != nil { + t.Fatal(err) + } +} + +func TestSessionInboundQueueFullCancelsCallback(t *testing.T) { + cfg := sessionTestConfig() + cfg.ReadQueueSize = 1 + cfg.ReadQueueBytes = 8 + entered := make(chan struct{}) + s, peer := sessionPair(t, cfg, Handlers{OnMessage: func(ctx context.Context, _ *Session, _ Message) error { close(entered); <-ctx.Done(); return nil }}, true, false) + _ = peer.WriteMessage(websocket.TextMessage, []byte("one")) + <-entered + _ = peer.WriteMessage(websocket.TextMessage, []byte("two")) + _, _, err := peer.ReadMessage() + var ce *websocket.CloseError + if !errors.As(err, &ce) || ce.Code != 1008 { + t.Fatalf("close: %v", err) + } + if err := waitSession(t, s); !errors.Is(err, ErrQueueFull) { + t.Fatalf("session: %v", err) + } +} + +func TestSessionCompressedReadLimitAndInvalidUTF8(t *testing.T) { + for _, tc := range []struct { + name string + data []byte + code int + cause error + }{ + {"compressed", []byte(strings.Repeat("x", 4096)), 1009, ErrMessageTooLarge}, + {"utf8", []byte{0xff}, 1007, ErrInvalidMessage}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := sessionTestConfig() + cfg.MaxMessageBytes = 128 + s, peer := sessionPair(t, cfg, Handlers{}, true, true) + if err := peer.WriteMessage(websocket.TextMessage, tc.data); err != nil { + t.Fatal(err) + } + _, _, err := peer.ReadMessage() + var ce *websocket.CloseError + if !errors.As(err, &ce) || ce.Code != tc.code { + t.Fatalf("close: %v", err) + } + if err := waitSession(t, s); !errors.Is(err, tc.cause) { + t.Fatalf("limit: %v", err) + } + }) + } +} + +func TestSessionCallbackPanicAndCloseBarrier(t *testing.T) { + closeEntered := make(chan struct{}) + release := make(chan struct{}) + s, peer := sessionPair(t, sessionTestConfig(), Handlers{ + OnMessage: func(context.Context, *Session, Message) error { panic("secret peer data") }, + OnClose: func(*Session, error) { close(closeEntered); <-release }, + }, true, false) + _ = peer.WriteMessage(websocket.TextMessage, []byte("panic")) + _, _, _ = peer.ReadMessage() + <-closeEntered + select { + case <-s.Done(): + t.Fatal("Done ignored running OnClose") + default: + } + close(release) + if err := waitSession(t, s); err == nil || strings.Contains(err.Error(), "secret") { + t.Fatalf("panic error: %v", err) + } +} + +func TestSessionHeartbeatDetectsUnresponsivePeer(t *testing.T) { + cfg := SessionConfig{PingInterval: 10 * time.Millisecond, PongTimeout: 30 * time.Millisecond, CloseTimeout: 30 * time.Millisecond} + s, _ := sessionPair(t, cfg, Handlers{}, true, false) + if err := waitSession(t, s); err == nil { + t.Fatal("peer without reader remained healthy") + } +} + +func TestSessionWriteCancellationStopsConnection(t *testing.T) { + cfg := sessionTestConfig() + cfg.MaxMessageBytes = 16 << 20 + cfg.WriteQueueBytes = 16 << 20 + s, _ := sessionPair(t, cfg, Handlers{}, true, false) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + err := s.Send(ctx, Message{Type: Binary, Data: make([]byte, 16<<20)}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("send: %v", err) + } + if err := waitSession(t, s); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("write cancellation: %v", err) + } +} + +func TestSessionValidationAndRedactedErrors(t *testing.T) { + for _, cfg := range []SessionConfig{{MaxMessageBytes: -1}, {ReadQueueSize: -1}, {WriteQueueBytes: -1}, {WriteTimeout: -1}, {PingInterval: time.Second, PongTimeout: time.Second}} { + if cfg.Validate() == nil { + t.Fatalf("invalid config accepted: %+v", cfg) + } + } + s, _ := sessionPair(t, sessionTestConfig(), Handlers{}, false, false) + for _, msg := range []Message{{Type: 9}, {Type: Text, Data: []byte{0xff}}} { + if !errors.Is(s.TrySend(msg), ErrInvalidMessage) { + t.Fatal("invalid message accepted") + } + } + if !errors.Is(s.TrySend(Message{Type: Binary, Data: make([]byte, (1<<20)+1)}), ErrMessageTooLarge) { + t.Fatal("large outbound accepted") + } + for _, code := range []int{1005, 1006, 1015, 5000} { + if s.Close(code, "") == nil { + t.Fatalf("invalid close %d", code) + } + } + secret := fmt.Errorf("https://user:password@example.test?token=secret") + for _, err := range []error{&OpError{Op: "dial", Cause: secret}, &HandshakeError{StatusCode: 401, Cause: secret}, &CloseError{Code: 1008, Cause: secret}} { + if strings.Contains(err.Error(), "secret") || !errors.Is(err, secret) { + t.Fatalf("error wrapping: %v", err) + } + } +} + +func BenchmarkSessionSend(b *testing.B) { + s, peer := sessionPair(b, sessionTestConfig(), Handlers{}, true, false) + readerDone := make(chan struct{}) + go func() { + defer close(readerDone) + for { + if _, _, err := peer.ReadMessage(); err != nil { + return + } + } + }() + msg := Message{Type: Binary, Data: make([]byte, 256)} + b.ReportAllocs() + b.SetBytes(256) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := s.Send(context.Background(), msg); err != nil { + b.Fatal(err) + } + } + b.StopTimer() + _ = s.Close(1000, "") + <-readerDone +} diff --git a/ws/testdata/fuzz/FuzzOrigin/1701c6390d7ef919 b/ws/testdata/fuzz/FuzzOrigin/1701c6390d7ef919 new file mode 100644 index 0000000..7c9b702 --- /dev/null +++ b/ws/testdata/fuzz/FuzzOrigin/1701c6390d7ef919 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("https://::443")