Compare commits

...

6 Commits

67 changed files with 8693 additions and 25 deletions

View File

@@ -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;不绑定任何网关名称。

View File

@@ -1,4 +1,4 @@
# 通用业务开发指南(v0.30.0-preview)
# 通用业务开发指南(v0.31.0-preview)
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。

View File

@@ -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

View File

@@ -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#准备环境与安装)。

View File

@@ -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)。
## 按需深入

View File

@@ -1,3 +1,23 @@
# 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 源码兼容,依赖安全升级需单独完成并回归,详情和证据见上述验收报告。
远程空缓存验收通过:WS 的 6 个包、60 个顶层 race 测试和 11 项发布检查;原有 API 工程 110 项、通用工程 30 项、混合工作区 16 项检查。四组均使用固定远程版本,无本地 replace,模块校验和一致。标签固定在 `3272974bc1cd19c79efe7354b00b6ed67f27f51e`;见 [WS](reports/websocket-remote.json)、[API](reports/websocket-managed-remote.json)、[通用工程](reports/websocket-program-remote.json)、[工作区](reports/websocket-workspace-remote.json) 报告。
# v0.30.0-preview
新增通用 `telemetry.HeaderPropagator`,让服务在不修改业务 Handler 的情况下接入使用自定义 Trace/Span/Request Header 的上游网关。配置 Header 名称后,入站建立远程父 Span,`logx.TraceID` / `RequestID`、访问日志和审计沿用上下文;出站 `telemetry.Transport` 同时注入 W3C 和兼容 Header。

View File

@@ -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
}

View File

@@ -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` 命令的手动路由装配。
## 数据库迁移命令

View File

@@ -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")}
}
`

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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"

View File

@@ -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.

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
# 以下文件仅是本次新生成示例的占位实现,替换为示例自身维护的逻辑。

View File

@@ -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)。

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>GoOmni WebSocket 示例</title>
<style>body{font:16px system-ui;max-width:720px;margin:60px auto;padding:0 24px;color:#182b3d}button,input{font:inherit;padding:10px;margin:4px}pre{white-space:pre-wrap;background:#f2f5f8;padding:20px;min-height:180px}#state{color:#52697b}</style>
<h1>WebSocket 回显与分组广播</h1><p>打开两个标签页,连接后发送文本或广播。</p>
<p id="state">未连接</p><button id="connect">连接</button><button id="disconnect">断开</button>
<p><input id="message" value="你好 WebSocket" aria-label="消息"><button id="send">发送</button><button id="broadcast">广播</button></p><pre id="log"></pre>
<script>
let socket; const log=document.querySelector('#log'),state=document.querySelector('#state');
function append(text){log.textContent=(log.textContent+text+'\n').slice(-12000)}
document.querySelector('#connect').onclick=()=>{if(socket&&socket.readyState<2)return;socket=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');socket.onopen=()=>state.textContent='已连接';socket.onmessage=e=>append('收到:'+e.data);socket.onclose=e=>state.textContent='已断开,关闭码 '+e.code;socket.onerror=()=>append('连接发生错误')};
function send(value){if(socket?.readyState===WebSocket.OPEN){socket.send(value);append('发送:'+value)}else append('请先连接')}
document.querySelector('#send').onclick=()=>send(document.querySelector('#message').value);
document.querySelector('#broadcast').onclick=()=>send('broadcast');
document.querySelector('#disconnect').onclick=()=>socket?.close(1000,'');
</script></html>

107
examples/websocket/main.go Normal file
View File

@@ -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
}

View File

@@ -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)。

View File

@@ -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
}

View File

@@ -0,0 +1,9 @@
package main
import "testing"
func TestBidirectionalRPCAndRestore(t *testing.T) {
if err := run(); err != nil {
t.Fatal(err)
}
}

1
go.mod
View File

@@ -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

2
go.sum
View File

@@ -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=

View File

@@ -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) {

View File

@@ -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。

View File

@@ -13,6 +13,7 @@
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 GOTOOLCHAIN=go1.22.12 go test -count=1 ./...`:通过。
- `go vet ./...`、`go build ./...`、`git diff --check`:通过。
- `go run ./examples/telemetryHeaders`:通过,示例打印固定 Trace ID、Request ID 和 JSON 响应。
- 发布后空模块缓存验收:`verifyManagedRelease.py` 通过 110 项,`verifyProgramRelease.py` 通过 30 项,`verifyWorkspaceRelease.py` 通过 16 项;三份结果分别见 `headerPropagationRemote.json`、`headerPropagationProgramRemote.json` 和 `headerPropagationWorkspaceRemote.json`。
完整竞态套件在高并发生成器生命周期用例中出现了已有的时序超时(`TestGeneratedMainLifecycle/drainBeforeClose`、`TestGeneratedBackgroundPhases/nethttp`);单独重跑受影响的 `TestManagedGeneratedProject` 通过,功能定向竞态测试通过,未发现本次 Header 传播相关竞态。

View File

@@ -0,0 +1,74 @@
{
"passed": true,
"frameworkVersion": "v0.30.0-preview",
"frameworkSum": "h1:sUtS24GAmm1QQYYqdV2XWZ3STR2V/KPqtxRcXGokH48=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 21.72,
"tests": [
"TestConflictRollsBackEarlierWrites",
"TestUnsafePaths",
"TestGeneratedProgramConsumer",
"TestOwnershipAndDrift",
"TestRecordedTemplateUpgrade",
"TestProjectGuards/module",
"TestProjectGuards/oldFramework",
"TestProjectGuards/manifest",
"TestProjectGuards/lock",
"TestProjectGuards/otherGenerator",
"TestProjectGuards/symlink",
"TestProjectGuards"
],
"checks": [
"remoteCLIInstall",
"installedVersionDefault",
"generatedConsumerBuildRaceVet",
"defaultNoAPIFiles",
"defaultNoNetworkListener",
"finiteProgram",
"SIGINT",
"SIGTERM",
"initializationFailureAndPanic",
"runErrorPanicGoexit",
"fatalComponentFailure",
"nonzeroProcessFailureAndTimeoutExits",
"partialStartupDraining",
"acknowledgementBeforeResourceClose",
"workContextSurvivesGracefulStop",
"cancellationGrace",
"unresponsiveRunAndBackgroundWork",
"retainedResourcesAndLogger",
"explicitConfigurationOnly",
"checkConfigWithoutDependencies",
"handwrittenFilesPreserved",
"generatedFileConflict",
"missingGeneratedFileRestored",
"missingHandwrittenFileNotRecreated",
"readOnlyCheckAndDryRun",
"templateDriftUpgrade",
"manifestIdentityAndVersionGuards",
"symlinkAndLockProtection",
"fileTransactionRollback",
"publishedExampleFiniteRun"
],
"sourceHashes": {
"cmd/go-omni/internal/genfs/files.go": "97e338c74ddb3145eb0ec942b69d5c21c83dfb2f9f2165376fc83366ae1e93af",
"cmd/go-omni/internal/genfs/files_test.go": "a659b31ddf195c80d48cfc00302a3f4c6d5a1ed68b58d241da03a44ad54fe34a",
"cmd/go-omni/project.go": "07e362664053e59dbb8f8f46d4c7be9d66afb93a7142a810109b66cd296ae8a6",
"cmd/go-omni/projectgen/consumer_test.go": "312442d56599c8264f14aef03722a221a72e70c038875f11c3dbfc19e9872883",
"cmd/go-omni/projectgen/generate.go": "19afc56ed90fbdb7e6c77b5a7509a63d5176c0b576946e42b4241942b7dd1da6",
"cmd/go-omni/projectgen/generate_test.go": "f113258f0150a9f569dbe422b9f40e8ba0db3d115caf09a2c81432a8ae126e03",
"cmd/go-omni/projectgen/templates/.gitignore.tmpl": "82abf6aa84ec1c3ec218b09b5ce465af616c4fe8ff6de6db09abef962a137401",
"cmd/go-omni/projectgen/templates/README.md.tmpl": "3d01821fb4af9e97ca92f47e80a520d6f64ef136e49a8bcd4fed53dec32bde23",
"cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl": "c224c68f50ea00a10e9dfd317347ac843c9107738861dab7824f7a7b1ae3a1ad",
"cmd/go-omni/projectgen/templates/internal_config_config.go.tmpl": "55111dbe02f0b91ed58a36449dcee8c9d7338251730764cda0de3e77e4a82f28",
"cmd/go-omni/projectgen/templates/internal_config_loadGen.go.tmpl": "95fb848065e3e516a8026f4439ede6d8be100e1726a56a124a6e49cb1d9d817d",
"cmd/go-omni/projectgen/templates/internal_config_loader.go.tmpl": "c9ad9f26a7d84622d7f2114a020b57ba6871a86ccf6cbc015c63674026ce81c3",
"cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl": "51c2b4b407aa8b350e666be49b9d888ee42070627e28303ae2e1b0c9b4d73c95",
"cmd/go-omni/projectgen/templates/internal_svc_runtimeGen.go.tmpl": "8e259634ac7fbac0d2a13629864b2a5c89e43b6e9c477db9272b00cf49dc47d1",
"cmd/go-omni/projectgen/templates/internal_svc_serviceContext.go.tmpl": "1ed62fd86fd050468cac1c158a438deed01d4c25b4b634eec4ccd84b24df476f",
"cmd/go-omni/projectgen/templates/main.go.tmpl": "c59a7773f00c31c1a67e6a0093de26bc6f2dc8dbe34c61c1fa7e1b341e4afcc2",
"cmd/go-omni/projectgen/testdata/consumer_test.go": "38df45a69e843dfe6897d0296337e10b507076ea3c7a32901c1cad340915b0ee",
"scripts/verifyProgramRelease.py": "9127743ee929cb32701e4a063372a817ead3fd2795c2722b75f2a0c9123fa85a"
}
}

View File

@@ -0,0 +1,341 @@
{
"passed": true,
"frameworkVersion": "v0.30.0-preview",
"frameworkSum": "h1:sUtS24GAmm1QQYYqdV2XWZ3STR2V/KPqtxRcXGokH48=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 145.32,
"checks": [
"remoteCLIInstall",
"freshModuleCache",
"fixedVersionNoReplace",
"contractChangePreview",
"persistentLogicSignatureFailure",
"businessSourcePreserved",
"fixedConsumerBuild",
"rootGoRun",
"nethttpAndNativeSignals",
"listenerReleased",
"default404And405",
"goOwnedErrorCatalog",
"unwrappedDownloads",
"rawCallback",
"multipart400And413",
"nethttpAndNativeTransfers",
"sharedGoCatalogExport",
"documentOnlyErrorSelection",
"workspaceCheckNoWrites",
"staleCatalogRejected",
"twoIndependentServiceBuilds",
"initializationAndListenFailureCleanup",
"drainBeforeDependencyClose",
"generatedConsumerRaceTests",
"errorParamsAndInheritance",
"manualHooksAndScalars",
"dependencyFatalShutdown",
"fatalProcessExitCode",
"initializationShutdownNotification",
"configuredBuiltinEndpoints",
"projectHealthEnvelope",
"endpointConflictCleanup",
"responseArrayNullableNull",
"primitiveAndDynamicArrays",
"strictRequestPolicies",
"declarativeMultipartBinding",
"backgroundStopDrainResourceOrder",
"backgroundInitAndListenFailureCleanup",
"backgroundTimeoutRetainsResourcesAndExits",
"generatedIncompleteHTTPDrainRetainsDependenciesAndLogger",
"recursiveResponseTypedHTTP",
"recursiveResponseReferenceGraph",
"recursiveResponseRegenerationAndDrift",
"remoteHTTPClientRaceTests",
"remoteHTTPProxyAndTimeouts",
"remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets",
"remoteHTTPExample",
"remoteExplicitConfigurationRaceTests",
"literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies",
"removedImplicitEnvironmentRules",
"remoteSignedReadOptionsAndLegacyLinks",
"remoteReadSignatureTamperingAndExpiry",
"remoteLocalPayloadRangesAndHEAD",
"remoteOSSNativeV1V4Signatures",
"remoteS3R2ReadOptions",
"remoteSignedReadHTTPExample",
"remoteConfiguredHeaderPropagation",
"remoteHeaderW3CPrecedenceAndValidation",
"remoteHeaderTelemetryExample",
"remotePreciseModuleDiagnostics",
"remoteJSONSuccessStatuses",
"remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight",
"remoteEmptyCollectionTypeRejection",
"remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace",
"remoteCORSErrorResponses",
"remoteCORSTransports",
"generatedCORSConfigOnly",
"remoteUnifiedBodyPolicyTransports",
"remoteBodyLimitsBeforeMiddleware",
"remoteHardLimitConflictBeforeServing",
"remoteCanceledHandlerCleanupBarrier",
"remoteExternalServingResourceRetention",
"remoteAdmissionCloseRace",
"remoteRuntimeFinishRetainsHTTPDependencies",
"remoteHTTP2DisconnectedHandlerDrain",
"remoteNativeEarly413AndBusinessHTTP200",
"remoteHTTPSafetyExampleBuild",
"remoteTransactionStages",
"remoteTransactionCauseChains",
"remoteCommitOutcomeUnknown",
"remoteCancellationOutcomeUnknown",
"remoteTransactionPanicAndConcurrency",
"remoteTransactionPublicErrors",
"remoteTransactionExample",
"remoteI18nRuntimeRace",
"remoteI18nGeneratedConsumer",
"remoteI18nHTTPBusinessInvariants",
"remoteI18nSourceOnlyFallback",
"remoteCLDRSamplesAndBrowserConformance",
"remoteI18nCLIExtractionCheckExportTypes",
"remoteI18nNamespaceIsolation",
"remoteBrowserRaceAndFailureRetention",
"remoteJSONReaderCausePreservation",
"remoteTypeScriptConsumerSkippedWithoutTSC",
"flatFieldComposition",
"copyxThreeEndpointConsumer",
"explicitNullAndMoneyPolicies",
"fieldAdditionWithoutCopyRuleChanges",
"middlewareFactoryInjectionAndCleanup",
"optionalRepositoryPreservesBusiness",
"generatedUnifiedConfig",
"jsonYamlXmlAndCustomDecoder",
"configValidationBeforeDependencies",
"configEnvProvenance",
"configExtensionPreserved",
"configLoaderDriftCheck"
],
"sourceHashes": {
"lifecycle/runtime.go": "d34fb26879932153671b9cbd164daae484b23dca08b8a3be5b1b89f8f6168e47",
"lifecycle/phases.go": "6e72499d2c5fcecb57cf712e76e3a03fed61a4caf528f873f2058291a8ab0b4d",
"config/lifecycle_test.go": "fe31c371acc3b2df2f58ecfb7b6a0f29d444410f43c0d49bad6cbf72a1a87248",
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
"cmd/go-omni/apigen/templates.go": "866a7ab9cc28f987645785203213e10722490970d5a1fd3c060d88db8b1278dd",
"config/project.go": "169e13921afa6a9336fcde3310675580799335ce55f076cbe60cdf3cfbd8cac2",
"contract/bind.go": "25ef07654abe42fe5f7e9cf5e5aa3bf102b311502bdbf16c0346970f87ee3053",
"contract/policy.go": "2c8e7efb07d59fea2c00ab39694d4df52a5bea6495ce23cac1c470584af74da1",
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
"cmd/go-omni/apigen/generate.go": "4eb7f9bed7f1a9823870275eadedeb87e7400a6cb07c7ac6c2e8c310fdafc9bf",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
"app/app.go": "2e672b3f6376049dd9695f799698439909f1d649d4338d13478e0dbf1de68800",
"app/transport.go": "8c665f47447d9839bba7ba3efa290316be9635179d2276b0da0d0f8eda3842ec",
"scripts/verifyManagedRelease.py": "7c7be0eff8eecdc0de54f6c619e00e4150e02f6511bd87fb78c12d6addd4a531",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
"cmd/go-omni/apigen/testdata/managed/client.go": "cb4a07539adbd163c13fc6cbb914518506b0c60a88473589f212f3406e763e4b",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "b078bd9052e513ab6173a1e65cf54b28d3cfad445cb50518d9e2e76736071047",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "a1d91744abe020cbfb476512cffb7dad422a95da1c3be6957fdcfe27c361030b",
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
"cmd/go-omni/apigen/responseShape.go": "b0d573b5dbdfe397b0ca2bfc2e79c85c589bd11b2269f7206b3828cf0ff5afd7",
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
"config/references.go": "ea709ddb32a9c5601662a875765d376b8490c4c766ecdb4365f462b5a2b10e16",
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
"cmd/go-omni/apigen/generatorConfig.go": "78db0c7808cf6ec997c5b64a4a5fbea11b74049709bc5900ccfbc5d8dabebbc8",
"cmd/go-omni/apigen/copyConsumer_test.go": "e08ae4daed5dd25ab89d8872f653caf19499984dcaddc9835c13884c410fc0c3",
"cmd/go-omni/apigen/generatorConfig_test.go": "a6d8c8ebea5f1accf0e37dd044e57b48e422821fe75086d8d2eb3abe3acb147c",
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
"cmd/go-omni/apigen/testdata/composition/domain.go": "ab27ebd7ad5e3ed5505a2aa268b9b5ceedbb01477b59011bec63bb270114078b",
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
"cmd/go-omni/apigen/testdata/composition/copy.go": "32d37d6f1f0e9aa2409aeb90b052801d1cdca70e504c2dd294cdae01422c9cb7",
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "74aaf60318cd0de67495fc9142b3f67c3b6e232bb48b46b3e134de5aadd225ad",
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
"cmd/go-omni/apigen/testdata/composition/converters.go": "ecdcfe31c75ca815dab8d1a82533b12cbbd40e12b3a5c444e815097f6c4df08e",
"copyx/check_test.go": "4095de3e3ba946573edb0b2baad5b492eb559565745693e144ca008eb740e1a2",
"copyx/check.go": "aa53985a2d8b73a18c51d112552bfecd06b60079cd119ebeed5ef4a5d3b0db22",
"copyx/options.go": "edea2f3d2043bf813e56dedf0aea319aaa52301d3b589f41fef986e33a77dc32",
"copyx/copy.go": "179a5a1be871813765074d945aa341945d2a4a9a42928e2b30e9839f9795aa9a",
"copyx/graph.go": "ae6a2a11a8175033d327b580cd570c97fe9d2ddb814bb50884cc19a90766badd",
"copyx/benchmark_test.go": "b1bf408957cadea47b47215ffb099d140d5d1a2eb061024a6d0c1d769f469a79",
"copyx/copy_test.go": "89b65a6281591d7cd5823cc1c7d2c384c6bd446ef06aa1763ddc3acaa84dfb4a",
"config/decoders.go": "6d31f67c82563c2d63e37ad1ce9b9fbad6232b2b785b9b58dd025be857f0fc28",
"config/config.go": "cbf09c158a9add8289befd9960f911a8cf42de0b840a57278bc71bf800121ec8",
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
"config/http_test.go": "975dc288486ba537cf7fd7ee87df792ba7eceed6ab0a58e6a5c0660ce724ab9d",
"config/response_test.go": "b4ccb111f813f80dc180a7ddb11034a409b9409cbb1b57e654c04d678465d201",
"config/projectAdvanced_test.go": "48b5493fe364e1022d63f1f2b556e2e1554b14af8f994074ec9b048fc6108fa4",
"config/file_test.go": "019ad4356838a592826daaf3bdcc7706b74a0d7782abcbe9bd6aba161aaa6576",
"config/moduleDiagnostics_test.go": "68b9bba485617e3cde57773b8c24cd9554636f6364151bbfcad48548408c83b4",
"config/references_test.go": "e19f0a42bbe08d1a723aff63e2d77d22feba08da98ecf5d47aa675e7105da850",
"config/errorParams_test.go": "fe61921a2e5fbdf32a623299fec42799287b0af1ab19b2f8fdd4bc97583e582d",
"config/http.go": "d02858d522ad63e86185f5d87d3fec9718141c75b7199fe75d0894dfa016d24d",
"config/loader.go": "50a2347960cc98f337b7a179e3054af0bfd22db879b58bee4ffd09e3f693d051",
"config/file.go": "2fbf4cd55d13944d7a7a208696f4a845a2089058d5f6e463c1e769e56bf77404",
"config/diagnostic.go": "cf818cd90941a5c2d9a6279dc4c3b90d71495db22846625fdf769dadefcf077a",
"config/bind.go": "0eabe1534eb671f13c938021183a1f3df829283d6fb587b849d22253dda278b2",
"config/clone.go": "6146bf8a7d7549d975142a843db5c6f95c67e98b62cbd98e20c77842abd38040",
"config/endpoints_test.go": "3368cd6af3e5902a1a4f7ea9f67e2da9e3d525f5c63f7e78aaeaae8fdfbf8394",
"config/config_test.go": "112202bdd153e9e87a43629b842e02caaad54e9516e1f7cd18c301f74cb69cc7",
"config/loader_test.go": "854de1586e64ac00acd7970be929af8a1b17d9a027bf92544f6221ccef38ceb9",
"cmd/go-omni/apigen/configLoader_test.go": "75c47861be382558434883d25c8deb1a567bd305290db624e572d2dcf302afbf",
"cmd/go-omni/apigen/testdata/configuration/consumer_test.go": "0cf2d8b6fabf8dc4652d33cadb5a462e6f3a527463d8965fcb54123ae6cb6cc4",
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
"httpc/config.go": "08241015b758648b0eb793e1856b88898ec57d8021c8e542db7d1dd6a2dcf2e6",
"httpc/request_test.go": "ae410eaa2cac54bbc15515a524387dde7bd7aa61a8fcc8e820108cbcc85c7b98",
"httpc/request.go": "43c1a4611331c8ac81eab98d5782fe8d4d271c14daa219e8a2134e029b65064d",
"httpc/status_test.go": "4f71d3f4aede466c02f978c5b4a4d3ffc83edee5916ce8d0505d01878ddef2a5",
"httpc/client.go": "a5a99b70bd1b1828a260bb41a6d9dc42d85f18a6b33ec822315374871d6af959",
"httpc/benchmark_test.go": "3ac0ccfb227b00fcbca42f89686fb67e3e00f4ed23ac5dfa686663ae18843a07",
"httpc/config_test.go": "84bc068fdc7964ae16c133374ecd6e07278253e33cea132ebcafc681378d7559",
"httpc/client_test.go": "972ebd2efab618629e394484c288afe4e675fcfe0c50121d9fa9071680d6e763",
"httpc/json.go": "e4a3caf19f24c3f58494311cd887dbb00acbdb4d9efc1f36988e19cb0f9abe7c",
"examples/httpClient/main.go": "8572f68d1bdbe9e3ce36b6f53d6ac7ef98966e0ac596ba57bb0811314a832eb5",
"examples/httpClient/main_test.go": "32f5513b2a2702214882c0163e0686548d8eb527977cde23829b54175d95010e",
"logx/logx_test.go": "0f99eff445decdd81a55b3d5374c22d2e038d2a4748adb2c9367d6d03e481671",
"logx/redaction.go": "e288c01cb0e30a79855b9e1b886a802c5614298aba5f39b2619eafb1f187f9ae",
"logx/adapters_test.go": "f0c57ca90075c82f9552ec43e58ab0613b43dea89411d9db50ba5fcb5be3dddf",
"logx/production_test.go": "5a6f957d409d657a1e712e72aab3e3275b2ba21dc81dd3be69523c90d47cc458",
"logx/timeZone_test.go": "523a864a1d55e32a770b9816634d6445ad468cab2e7d8d86588b1c428ec04824",
"logx/adapters.go": "5b558685e88cb37889238f73e9670d4db2b509fd39d512ddfca25de1b5f4aea8",
"logx/logx.go": "18dd28f8378e403773e238d1c8ce4acd05838d2863475ab7a5e278f5ba7cb6fb",
"logx/timeZone.go": "1b82737362f880f4a8461fd20637ef64eef5a5db475a49e0c9daa27a6ca7b636",
"logx/rotation.go": "e278c5ffbdba36c39b15c8b31299abe402695edaf525eb5b03d4328cf35fce61",
"internal/configdiag/error.go": "a25f9f398cd0e5ea1a0d049c8a7c264c85be67c469619b41220cf56afffdbda6",
"database/mysql/mysql.go": "e5f95b97dd79eb64d493255d2fd2f5895538131021eb8a6068de8ff6f484702c",
"database/connector.go": "5a973ea4bc53fee2b72f1925f5cf4e3d83a3c935a1455406f4a7abd46c47c007",
"database/database.go": "5cf05972b56870d89da5290e3b0db777b0253c3b4214cee3d47ab7fa6d76a263",
"examples/developerTools/config.yaml": "1e672ba1b0b16a417840a09468f785776c51408ee973b55b1910cb736a8e29d9",
"examples/developerTools/README.md": "3ba0d700dc3e3788a2a57367273b8d6b0f2acbf2f6cf38149edeef84ce898908",
"examples/developerTools/main.go": "a799d3106b4a64258b7c1cbad9c776cab840526795416b07b114628e3bd4c440",
"cors/cors.go": "a0feb191b5ecc43b0a0c889af5c1ec960fdc55bfa813aff49413008077e64f70",
"cors/config.go": "973300087dee87fd35bb56a2c40b6ace75f85b08e6749cc509421aab9135e2e4",
"cors/cors_test.go": "49b20eb72ba4c40b949a711fba0bdcd342d793a8a010771204e696c563b3e08c",
"app/cors_test.go": "7dcfcbb9c605a16bbd5455177a1f527dbb18f673e6ebb2b93d7b874c64c5d56a",
"cmd/go-omni/apigen/corsConsumer_test.go": "8b5ce07474f50d89ba1d7d155b433e7103785b350db13df8cacd527b7070e240",
"cmd/go-omni/apigen/testdata/cors/consumer_test.go": "677c559b8c6fc77aa6d8a69c0c1296f1fbefaa7f2f64cf80609dbe4027e254ab",
"examples/cors/main.go": "39d76c4e2a4929f18f2acbfe03ab10c50b7276e56345de4d12eb962b5f6cc5b7",
"examples/cors/config.yaml": "a78f83433810cb385ac9a8debbeb6932ebef71d85df073afbfc1d928fb7bf89f",
"scripts/corsBrowser/main.go": "2654778e9c32829f6dadefe572551f9feb891da110741a08bf9243c7284c3ba2",
"database/transaction.go": "037f14ed495e6d024e13e2033c2e469b138dc5c1e1d6f6a9a73e723e094e71e1",
"database/transaction_test.go": "b8fb122c145836c07ca062dc8c89c07ae7482885d33c3f2c239cca21e97b72d4",
"database/transactionResponse_test.go": "bc59b17605c5b5e051fc987caf6dafbbb5d71f4b1380a5ceec8f2dafaafb6af7",
"database/mysqlConnector_test.go": "db5a9f5a99fd46e8e84c4452663704172ddc26a57ad6af84d3165041ff5357e9",
"examples/transactions/main.go": "ac2a17556b63c0bbb8a5c726381b6929df235da089549937a70d3696e2cba3f2",
"examples/transactions/demoDriver.go": "950b5f9bf0d20b8b2bc7590d502e110f2815fc394df789cac39e324149870ab6",
"i18n/bundle.go": "cd0b54e779fa9f8e3a1b5d4c7456ae06f0f39618365362de329d4b61104abb93",
"i18n/types.go": "cc912f7c7dc598de758cec79568fd0bdfac6cab8a8333a6ce0570680236d415a",
"i18n/plural_test.go": "ad3a430f370bc51503aa5bce335dcb5962f6e8226f2925956ddd419fd9c230d8",
"i18n/export.go": "99608af1d487fced1bba49b77157e7a1f51afb4341fbb607f09920dfca94155a",
"i18n/pattern.go": "1c7a91e48f449124d2de90abebd698401aef93896fcdfcf24a83e12a048c5192",
"i18n/README.md": "11a933abe712c961ca1c3b138d4440bc440a7b4c6e30654849e604efe4be056c",
"i18n/plural.go": "9b69e23568a4ea240d5d4bd2c62237932245a8a91aa451386f4a7d9d76d774f9",
"i18n/load.go": "6e43d781ba90db6de78d2138e496161c391797a735211cae7342abebac2c5618",
"i18n/bundle_test.go": "f9e30d36955722f51e7d9dfe685515441451459547a3235b97064de7dda4d8f9",
"i18n/i18ntool/extract_test.go": "dd62077d190e4264348f18e8f86d25e324eebf2d471918f27da8f0c552bf46c6",
"i18n/i18ntool/extract.go": "f0ab49435f61750b06ee8105ff016be5fe06c8404f90163708d9f7e423ae89c0",
"i18n/errori18n/errors_test.go": "2c2b74b1752d4367035c9f6b9f0f79bd05dac583f37f4e4de97d2163e667be4d",
"i18n/errori18n/errors.go": "539a9eac6b841a566bc808363e9505226c7510d5dc277da621dbefb16cb06ecd",
"i18n/browser/runtime.d.mts": "fd2f18dbb6d1b1dfe2392a5e0831354f0fb2ac8815328d8b422ab79503889ad7",
"i18n/browser/runtime.test.mjs": "83b219f3b72cdeb417f6223f9f0942ef8d3b43d5051b1c66dc475c7634d65acf",
"i18n/browser/README.md": "8a3de821c6cdf57c99ee2b56fa0deeb7557b9d11a9a77bf05b00c1ae22bf34ab",
"i18n/browser/conformance.mjs": "95b7325ef24513b53eb5b8d3475fe3c5c7c56bc2da2cb1aa74e097b2ee410623",
"i18n/browser/assets.go": "a20c679902c5d96dba13b0df31ef98e3516f0ebdd1c403740a6a720a412b0b84",
"i18n/browser/runtime.mjs": "a38f6378a835661ee9198ff910eafd04f4e56051c113d0637703e59e4ae3cd3c",
"i18n/browser/testdata/consumer.mts": "84e3e26d2d86a03b30e3fcd27c3bb51c268ac89ef115f8def274321250b46e08",
"i18n/cldr/LICENSE": "220ba0e1c43b99530d2d5bdb892a99dca0989414f51ab695ecd90163eaa1ec3b",
"i18n/cldr/README.md": "cd5aef06e415cf21913a5b8736c29abbc02df449868c16cd08be8aa7e0c8fea0",
"i18n/cldr/plurals.json": "ce19668400e9f7904e8b3939d1f0a2872fe8da7bdea9f45f5d50b3a2bac3b382",
"i18n/httpi18n/http_test.go": "1f0266d2d2a54bfe767efe76bfedce6130f6c988dd04be801416336a95a5d606",
"i18n/httpi18n/http.go": "ac22eeb795b62b74d0b9a233b6fb79a337f35d430e6a136fb31f643ee4512f0d",
"examples/i18n/index.html": "7d0c9e1fdaf96c883cbe8e671ae094d83c2742a9f76b820649d58d62f4944c12",
"examples/i18n/config.json": "c3e12efcedbfc6a8ca73cae9cd4cc3617ee363af8747aec630808e6f5e1eb682",
"examples/i18n/README.md": "bf5c3ae49b921b1c25d06e19d5ecb743f349ea9d085b9d9da79f1391a29fa9e1",
"examples/i18n/main.go": "3f8599781e423a9e3a968f5310fcc80d2daf5f09b733050c1e13c4fea589f29e",
"examples/i18n/main_test.go": "c99f736841fe579d2134762d4962da73d6ab88090e5921ce8c26edf5134bee9b",
"examples/i18n/locales/ui.en.json": "fa8b210ad1a78baae8f444420c983c5498025a1a3ea0bd15d64d8125629bee42",
"examples/i18n/locales/ui.zh-Hans.json": "04761003bea2a02b1e746a1f458f50fa2e0fdb3706d02bc5cb5a4f16378c5021",
"examples/i18n/locales/errors.zh-Hans.json": "d9113b720f4c284b90c41a6db4a867d272db4e951cd284eeb00ca69e3fc21385",
"app/i18n_test.go": "4d6125b78c33c7df759584b84f791d9a041673d6536503c6671a71e20b588d39",
"errors/localization.go": "a047c33e9c4ad9ca10aacfa0800dec51a5db788c1299fba0fb9b361abef41c85",
"cmd/go-omni/i18n.go": "be95a89de3fcc34baaea76669f30616902b0f5d7d6ef018e9c29a8e38106fb6a",
"cmd/go-omni/apigen/i18nVersion.go": "c4187e5d77164b8e906294b63e8d351db49a6d587b4f77a32529f6a4b75cb1c9",
"cmd/go-omni/apigen/testdata/i18n/consumer_test.go": "b62ad30f5912c50e1f6bb1737fd75430ead113989990acb6dcdc93f5d88de0af",
"contract/policy_test.go": "d1ea81c923dda58ae75323cc1eb9773517e63180153dbdf19eb35a634c9890db",
"app/body.go": "4064b96fc38c5b75ec17f0d82e0dec79242640c4d0903bf544e4fda057e5397a",
"app/router.go": "b0418e18fd546b855a4cce9ff19efc25b274d9b9c52144e12338bb5d97a5be9f",
"app/requests.go": "c4cb453e8215ed594e6755b256b4b1bbcd3b870584f4be19480d4dd1b9e0aa3d",
"app/bodyPolicy_test.go": "505afa7bcc3f70009ef2af2c455e377277db01c2b883c8e5204de0cbebf25cbb",
"app/requests_test.go": "b008b5599e1f5eea01a08f15220588bd581a2d5e98658d53f0acb273dd946137",
"app/transport_test.go": "10f086eeb77e3b11e83768bbbb0372aa68056a8c8839a18edfaf597b5acff689",
"httpx/context.go": "e3a7d3cd31bc6bddaf21c2be35887cf5c85e69ec5e6ff82178884cb0e4696ffb",
"internal/httplimits/limits.go": "98ba40ceda78fcf4b08d51b0851de851329f568fd35b0a4aedefd4b48a43e780",
"lifecycle/runtime_test.go": "ccd302f78535215bea69d7c8ad8c812c79c52cca087353358206cb6b5926d34b",
"transport/http2/server.go": "e0b7fdb3069583d042df5117b36a3fffc423ee452b0a9da162e3604fcce80b49",
"transport/http2/lifecycle_test.go": "2a298b8e466ff4145105338904ec5d9e19087c91d829d6ae5cad8c8b1ee05b15",
"transport/native/server.go": "8c2fc503bc157265838b453bfe289ec68eeb2bdaf63f6dd1e585536a8278ba0c",
"transport/native/earlyReject_test.go": "0fd598e24199beb55bead10d741d739f7361345584c4430b4a97c35d59d62774",
"examples/httpSafety/main.go": "4651b3806e7a8030101ba6a70794fcc18a50d2939fe0a59d7f80bb4a7de30f15",
"storage/upload.go": "9ff2930ba389163acc8063b51107c09a60290609ba384de8004c498ce09f20a1",
"storage/read_test.go": "9b11ab9640e98c1c9fa7ce1e2587df217e874e3a6a6e27a0dd0ade6fbf3d5e1a",
"storage/signedUpload.go": "a8d31bcbbad64796f04611e69271e94d917c9bcd5aeabd7a97ea155b0093c814",
"storage/multipart_test.go": "4053f680f7795db2a6b4aa23590b3669eec7a609dd92c4a1e20891a5299274ee",
"storage/multipart.go": "3b718ebe6da681c89a3f797892c88e7acb15c295df2376d64ac35da0976ed69d",
"storage/signed_test.go": "eaffd40894018814e976477f96cf0fbaecfa1987caad78830d9fc8e96995882f",
"storage/upload_test.go": "12b60419807ed64b89e30861674e19abee7b0b7bb0e8ec9ac5b3aa2e0710a1da",
"storage/storage.go": "cb670872f01cf1d7b4ebf21cec9fc6f52ad93bfdf523b2f28bbb8d9c7fe39a6c",
"storage/signed.go": "ae4b144137627be35718c40743ebc2f063ec667c24b6d8984ccfcca034a73787",
"storage/read.go": "92787c74ae024822f82b5b84bffc9f8a355f1f2565b99ca7fbefe49157ef4851",
"storage/s3/upload.go": "3ce0b3626249ffe7a041606d6a9481743af8a0d4868fadea20ed3ed44ee0c765",
"storage/s3/read_test.go": "9b64fda5c83ba41508ec23cf41aeeb9826b677caaa851dcbeb04abe1f43eed65",
"storage/s3/config.go": "626ec6a2c0b06c95ee7eb7aa0832b190cfcdeb8a127b6b89e99204040bc3b041",
"storage/s3/upload_test.go": "a41fdbd100d9c79a3606c5942f98017afe26d841b251d96e979df87d0af93867",
"storage/s3/read.go": "319b57f2ecf435b71085c7cdcafd50a1e30caa5f357f83d4db3cdcef085792b6",
"storage/s3/s3_test.go": "9ef284926ec9936ddfec8c52a9bf04fc7b4be55c43a535f6c5a428d1535f6fec",
"storage/s3/s3.go": "016bef5948e97f3d043d2e858716f1c02d9f27c4e02802bdc1be3860116f4b6c",
"storage/local/read_test.go": "ef1f7feb0887ea4b1f30304dfaf542fe9788b6e17856c942f1cd98448656194f",
"storage/local/local_test.go": "3a2241840968df8e37fd67169c3ef708228449e81f65aff908c8b60d49434dc0",
"storage/local/local.go": "5d1ca75d0d2ca87981eef84a60bf98d0feefa2a9594940d6bd231877f4f8ec02",
"storage/oss/upload.go": "556129611f9e5549cac8a36b4026bfb19c112a295b55192579f9ac116e20626f",
"storage/oss/read_test.go": "a1c273cfedd2f3ee31ce407a7ddd6fd5ad02c65ad632a91bc7f3ead3712b5bbd",
"storage/oss/oss_test.go": "555a954010b1218804dc7951fc75e42eae24eba9a99c0871f5ba787f3fc87d45",
"storage/oss/upload_test.go": "08eb0ed157f3743288d781f61d4d2b7bfe77852a8259c5e598c719d35889409e",
"storage/oss/oss.go": "4216ce9f3fe3ac5418a5b809db3cef7e99dcca72a25a2a12301f5897cda85dd5",
"storage/oss/read.go": "429a9a9f6c586a885d7cccb76c9d572d53a79df637ff128caacb2eac35b8d862",
"storage/storagetest/contract.go": "e7ad7242102437882269d4066462059d7974586b36a82d18639eca10fd932be9",
"storage/storagetest/uploads.go": "9b66f86dd94ccdf30c7d603d432bc2a55956608898e46c1274fa27d16fa1a969",
"examples/signedRead/main.go": "f921a3c5fbcbfa84291c0c95a9c2c211319a8f0537acc5ec640238f251e652e1",
"examples/signedRead/main_test.go": "6ab559c442b2aa355231efe0342b9ec3617bf97bb1f6dfd7ad304b9a452e4fc4",
"telemetry/errorCatalog_test.go": "eb4f487e226dfb45f179dbe13b5eba5a23a478b3417eea7a9710230ce71f9ad5",
"telemetry/telemetry_test.go": "a44531e5c9efc69d99bf3fd73bafc904bc5dae0f886676cdcf0bda30552e4b3b",
"telemetry/telemetry.go": "043f295a62bef4d4a92b8918f6d201ac54a15afd6823a01d64ac0656c2bede5d",
"telemetry/adapters.go": "a892ef0c2b268caf266a3ea0f833ecf7e9228ff59aeaeecae6fe4a2170ba51e2",
"telemetry/headers.go": "a65f275a78e69bfe0b9c91bb7d4947609891ea34b6b7377e436b26b53f6ef74a",
"examples/telemetryHeaders/main.go": "f331d9cf2beb45d95fe1c3ba3e5d720e99b16e76511fe7d2c54bbcae97202592",
"examples/telemetryHeaders/main_test.go": "abe3a6d1d7f3348d0802dacc25f8fb267e592819949e567991be6dbd19783d88"
}
}

View File

@@ -0,0 +1,34 @@
{
"passed": true,
"frameworkVersion": "v0.30.0-preview",
"frameworkSum": "h1:sUtS24GAmm1QQYYqdV2XWZ3STR2V/KPqtxRcXGokH48=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 17.58,
"checks": [
"remoteCLIInstall",
"remoteAPIModule",
"remoteGenericModule",
"explicitKinds",
"manifestInferredKinds",
"mixedFormatWithProjectSkip",
"mixedGeneration",
"mixedCheck",
"nativeGoWorkBuild",
"nativeGoWorkRaceTests",
"moduleAndWorkspaceMetadataPreserved",
"bothProjectFailuresAggregated",
"nonzeroFailureExit",
"failedCheckIsReadOnly",
"crossProjectPreflight",
"missingGeneratedOutputRestored"
],
"sourceHashes": {
"cmd/go-omni/apigen/workspace.go": "cc39732ed9a41bb2dfa14933a830a7f10e8e3b2edc2ada07c376a738c0ec77e0",
"cmd/go-omni/apigen/workspaceMixed_test.go": "119f2a2aadc2b3ee2471bed1309c6ad8e36488b258f2ab1ba5ba5bab4219643f",
"cmd/go-omni/apigen/workspaceCatalog_test.go": "372874322aece46714351f088b5741642fe4e63d95062743a575958fa58b8b39",
"cmd/go-omni/workspace.go": "b7bbd3d9d58b3987090edf7e3714d7d8396e7c332174022d918a52e16396397d",
"cmd/go-omni/workspace_test.go": "0170d18158325262765d8b3067f55ca3ce9fe994f2d030cafee3e82b13bfcd4b",
"scripts/verifyWorkspaceRelease.py": "0495dc97c68ac17a7969088cf94b1cbbfa6e53f18fb36147c64bc34edb260bca"
}
}

View File

@@ -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

View File

@@ -0,0 +1,341 @@
{
"passed": true,
"frameworkVersion": "v0.31.0-preview",
"frameworkSum": "h1:d0sf951iJwMUtHj29D6BjUwOgg+C79GwQ+2vjAHLjDo=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 145.85,
"checks": [
"remoteCLIInstall",
"freshModuleCache",
"fixedVersionNoReplace",
"contractChangePreview",
"persistentLogicSignatureFailure",
"businessSourcePreserved",
"fixedConsumerBuild",
"rootGoRun",
"nethttpAndNativeSignals",
"listenerReleased",
"default404And405",
"goOwnedErrorCatalog",
"unwrappedDownloads",
"rawCallback",
"multipart400And413",
"nethttpAndNativeTransfers",
"sharedGoCatalogExport",
"documentOnlyErrorSelection",
"workspaceCheckNoWrites",
"staleCatalogRejected",
"twoIndependentServiceBuilds",
"initializationAndListenFailureCleanup",
"drainBeforeDependencyClose",
"generatedConsumerRaceTests",
"errorParamsAndInheritance",
"manualHooksAndScalars",
"dependencyFatalShutdown",
"fatalProcessExitCode",
"initializationShutdownNotification",
"configuredBuiltinEndpoints",
"projectHealthEnvelope",
"endpointConflictCleanup",
"responseArrayNullableNull",
"primitiveAndDynamicArrays",
"strictRequestPolicies",
"declarativeMultipartBinding",
"backgroundStopDrainResourceOrder",
"backgroundInitAndListenFailureCleanup",
"backgroundTimeoutRetainsResourcesAndExits",
"generatedIncompleteHTTPDrainRetainsDependenciesAndLogger",
"recursiveResponseTypedHTTP",
"recursiveResponseReferenceGraph",
"recursiveResponseRegenerationAndDrift",
"remoteHTTPClientRaceTests",
"remoteHTTPProxyAndTimeouts",
"remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets",
"remoteHTTPExample",
"remoteExplicitConfigurationRaceTests",
"literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies",
"removedImplicitEnvironmentRules",
"remoteSignedReadOptionsAndLegacyLinks",
"remoteReadSignatureTamperingAndExpiry",
"remoteLocalPayloadRangesAndHEAD",
"remoteOSSNativeV1V4Signatures",
"remoteS3R2ReadOptions",
"remoteSignedReadHTTPExample",
"remoteConfiguredHeaderPropagation",
"remoteHeaderW3CPrecedenceAndValidation",
"remoteHeaderTelemetryExample",
"remotePreciseModuleDiagnostics",
"remoteJSONSuccessStatuses",
"remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight",
"remoteEmptyCollectionTypeRejection",
"remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace",
"remoteCORSErrorResponses",
"remoteCORSTransports",
"generatedCORSConfigOnly",
"remoteUnifiedBodyPolicyTransports",
"remoteBodyLimitsBeforeMiddleware",
"remoteHardLimitConflictBeforeServing",
"remoteCanceledHandlerCleanupBarrier",
"remoteExternalServingResourceRetention",
"remoteAdmissionCloseRace",
"remoteRuntimeFinishRetainsHTTPDependencies",
"remoteHTTP2DisconnectedHandlerDrain",
"remoteNativeEarly413AndBusinessHTTP200",
"remoteHTTPSafetyExampleBuild",
"remoteTransactionStages",
"remoteTransactionCauseChains",
"remoteCommitOutcomeUnknown",
"remoteCancellationOutcomeUnknown",
"remoteTransactionPanicAndConcurrency",
"remoteTransactionPublicErrors",
"remoteTransactionExample",
"remoteI18nRuntimeRace",
"remoteI18nGeneratedConsumer",
"remoteI18nHTTPBusinessInvariants",
"remoteI18nSourceOnlyFallback",
"remoteCLDRSamplesAndBrowserConformance",
"remoteI18nCLIExtractionCheckExportTypes",
"remoteI18nNamespaceIsolation",
"remoteBrowserRaceAndFailureRetention",
"remoteJSONReaderCausePreservation",
"remoteTypeScriptConsumerSkippedWithoutTSC",
"flatFieldComposition",
"copyxThreeEndpointConsumer",
"explicitNullAndMoneyPolicies",
"fieldAdditionWithoutCopyRuleChanges",
"middlewareFactoryInjectionAndCleanup",
"optionalRepositoryPreservesBusiness",
"generatedUnifiedConfig",
"jsonYamlXmlAndCustomDecoder",
"configValidationBeforeDependencies",
"configEnvProvenance",
"configExtensionPreserved",
"configLoaderDriftCheck"
],
"sourceHashes": {
"lifecycle/runtime.go": "d34fb26879932153671b9cbd164daae484b23dca08b8a3be5b1b89f8f6168e47",
"lifecycle/phases.go": "6e72499d2c5fcecb57cf712e76e3a03fed61a4caf528f873f2058291a8ab0b4d",
"config/lifecycle_test.go": "fe31c371acc3b2df2f58ecfb7b6a0f29d444410f43c0d49bad6cbf72a1a87248",
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
"cmd/go-omni/apigen/templates.go": "866a7ab9cc28f987645785203213e10722490970d5a1fd3c060d88db8b1278dd",
"config/project.go": "169e13921afa6a9336fcde3310675580799335ce55f076cbe60cdf3cfbd8cac2",
"contract/bind.go": "25ef07654abe42fe5f7e9cf5e5aa3bf102b311502bdbf16c0346970f87ee3053",
"contract/policy.go": "2c8e7efb07d59fea2c00ab39694d4df52a5bea6495ce23cac1c470584af74da1",
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
"cmd/go-omni/apigen/generate.go": "ff56e82b6df4898177dbb45e9c183f21409aa5190a4a0e096bee06d4de7ad274",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
"app/app.go": "f7df6355c02050a265862e2e1ec3238cb05788017e871a2b15eed33fbab78e04",
"app/transport.go": "8c665f47447d9839bba7ba3efa290316be9635179d2276b0da0d0f8eda3842ec",
"scripts/verifyManagedRelease.py": "7c7be0eff8eecdc0de54f6c619e00e4150e02f6511bd87fb78c12d6addd4a531",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
"cmd/go-omni/apigen/testdata/managed/client.go": "cb4a07539adbd163c13fc6cbb914518506b0c60a88473589f212f3406e763e4b",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "b078bd9052e513ab6173a1e65cf54b28d3cfad445cb50518d9e2e76736071047",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "a1d91744abe020cbfb476512cffb7dad422a95da1c3be6957fdcfe27c361030b",
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
"cmd/go-omni/apigen/responseShape.go": "b0d573b5dbdfe397b0ca2bfc2e79c85c589bd11b2269f7206b3828cf0ff5afd7",
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
"config/references.go": "ea709ddb32a9c5601662a875765d376b8490c4c766ecdb4365f462b5a2b10e16",
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
"cmd/go-omni/apigen/generatorConfig.go": "78db0c7808cf6ec997c5b64a4a5fbea11b74049709bc5900ccfbc5d8dabebbc8",
"cmd/go-omni/apigen/copyConsumer_test.go": "e08ae4daed5dd25ab89d8872f653caf19499984dcaddc9835c13884c410fc0c3",
"cmd/go-omni/apigen/generatorConfig_test.go": "a6d8c8ebea5f1accf0e37dd044e57b48e422821fe75086d8d2eb3abe3acb147c",
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
"cmd/go-omni/apigen/testdata/composition/domain.go": "ab27ebd7ad5e3ed5505a2aa268b9b5ceedbb01477b59011bec63bb270114078b",
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
"cmd/go-omni/apigen/testdata/composition/copy.go": "32d37d6f1f0e9aa2409aeb90b052801d1cdca70e504c2dd294cdae01422c9cb7",
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "74aaf60318cd0de67495fc9142b3f67c3b6e232bb48b46b3e134de5aadd225ad",
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
"cmd/go-omni/apigen/testdata/composition/converters.go": "ecdcfe31c75ca815dab8d1a82533b12cbbd40e12b3a5c444e815097f6c4df08e",
"copyx/check_test.go": "4095de3e3ba946573edb0b2baad5b492eb559565745693e144ca008eb740e1a2",
"copyx/check.go": "aa53985a2d8b73a18c51d112552bfecd06b60079cd119ebeed5ef4a5d3b0db22",
"copyx/options.go": "edea2f3d2043bf813e56dedf0aea319aaa52301d3b589f41fef986e33a77dc32",
"copyx/copy.go": "179a5a1be871813765074d945aa341945d2a4a9a42928e2b30e9839f9795aa9a",
"copyx/graph.go": "ae6a2a11a8175033d327b580cd570c97fe9d2ddb814bb50884cc19a90766badd",
"copyx/benchmark_test.go": "b1bf408957cadea47b47215ffb099d140d5d1a2eb061024a6d0c1d769f469a79",
"copyx/copy_test.go": "89b65a6281591d7cd5823cc1c7d2c384c6bd446ef06aa1763ddc3acaa84dfb4a",
"config/decoders.go": "6d31f67c82563c2d63e37ad1ce9b9fbad6232b2b785b9b58dd025be857f0fc28",
"config/config.go": "cbf09c158a9add8289befd9960f911a8cf42de0b840a57278bc71bf800121ec8",
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
"config/http_test.go": "975dc288486ba537cf7fd7ee87df792ba7eceed6ab0a58e6a5c0660ce724ab9d",
"config/response_test.go": "b4ccb111f813f80dc180a7ddb11034a409b9409cbb1b57e654c04d678465d201",
"config/projectAdvanced_test.go": "48b5493fe364e1022d63f1f2b556e2e1554b14af8f994074ec9b048fc6108fa4",
"config/file_test.go": "019ad4356838a592826daaf3bdcc7706b74a0d7782abcbe9bd6aba161aaa6576",
"config/moduleDiagnostics_test.go": "68b9bba485617e3cde57773b8c24cd9554636f6364151bbfcad48548408c83b4",
"config/references_test.go": "e19f0a42bbe08d1a723aff63e2d77d22feba08da98ecf5d47aa675e7105da850",
"config/errorParams_test.go": "fe61921a2e5fbdf32a623299fec42799287b0af1ab19b2f8fdd4bc97583e582d",
"config/http.go": "d02858d522ad63e86185f5d87d3fec9718141c75b7199fe75d0894dfa016d24d",
"config/loader.go": "50a2347960cc98f337b7a179e3054af0bfd22db879b58bee4ffd09e3f693d051",
"config/file.go": "2fbf4cd55d13944d7a7a208696f4a845a2089058d5f6e463c1e769e56bf77404",
"config/diagnostic.go": "cf818cd90941a5c2d9a6279dc4c3b90d71495db22846625fdf769dadefcf077a",
"config/bind.go": "0eabe1534eb671f13c938021183a1f3df829283d6fb587b849d22253dda278b2",
"config/clone.go": "6146bf8a7d7549d975142a843db5c6f95c67e98b62cbd98e20c77842abd38040",
"config/endpoints_test.go": "3368cd6af3e5902a1a4f7ea9f67e2da9e3d525f5c63f7e78aaeaae8fdfbf8394",
"config/config_test.go": "112202bdd153e9e87a43629b842e02caaad54e9516e1f7cd18c301f74cb69cc7",
"config/loader_test.go": "854de1586e64ac00acd7970be929af8a1b17d9a027bf92544f6221ccef38ceb9",
"cmd/go-omni/apigen/configLoader_test.go": "75c47861be382558434883d25c8deb1a567bd305290db624e572d2dcf302afbf",
"cmd/go-omni/apigen/testdata/configuration/consumer_test.go": "0cf2d8b6fabf8dc4652d33cadb5a462e6f3a527463d8965fcb54123ae6cb6cc4",
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
"httpc/config.go": "08241015b758648b0eb793e1856b88898ec57d8021c8e542db7d1dd6a2dcf2e6",
"httpc/request_test.go": "ae410eaa2cac54bbc15515a524387dde7bd7aa61a8fcc8e820108cbcc85c7b98",
"httpc/request.go": "43c1a4611331c8ac81eab98d5782fe8d4d271c14daa219e8a2134e029b65064d",
"httpc/status_test.go": "4f71d3f4aede466c02f978c5b4a4d3ffc83edee5916ce8d0505d01878ddef2a5",
"httpc/client.go": "a5a99b70bd1b1828a260bb41a6d9dc42d85f18a6b33ec822315374871d6af959",
"httpc/benchmark_test.go": "3ac0ccfb227b00fcbca42f89686fb67e3e00f4ed23ac5dfa686663ae18843a07",
"httpc/config_test.go": "84bc068fdc7964ae16c133374ecd6e07278253e33cea132ebcafc681378d7559",
"httpc/client_test.go": "972ebd2efab618629e394484c288afe4e675fcfe0c50121d9fa9071680d6e763",
"httpc/json.go": "e4a3caf19f24c3f58494311cd887dbb00acbdb4d9efc1f36988e19cb0f9abe7c",
"examples/httpClient/main.go": "8572f68d1bdbe9e3ce36b6f53d6ac7ef98966e0ac596ba57bb0811314a832eb5",
"examples/httpClient/main_test.go": "32f5513b2a2702214882c0163e0686548d8eb527977cde23829b54175d95010e",
"logx/logx_test.go": "0f99eff445decdd81a55b3d5374c22d2e038d2a4748adb2c9367d6d03e481671",
"logx/redaction.go": "e288c01cb0e30a79855b9e1b886a802c5614298aba5f39b2619eafb1f187f9ae",
"logx/adapters_test.go": "f0c57ca90075c82f9552ec43e58ab0613b43dea89411d9db50ba5fcb5be3dddf",
"logx/production_test.go": "5a6f957d409d657a1e712e72aab3e3275b2ba21dc81dd3be69523c90d47cc458",
"logx/timeZone_test.go": "523a864a1d55e32a770b9816634d6445ad468cab2e7d8d86588b1c428ec04824",
"logx/adapters.go": "5b558685e88cb37889238f73e9670d4db2b509fd39d512ddfca25de1b5f4aea8",
"logx/logx.go": "18dd28f8378e403773e238d1c8ce4acd05838d2863475ab7a5e278f5ba7cb6fb",
"logx/timeZone.go": "1b82737362f880f4a8461fd20637ef64eef5a5db475a49e0c9daa27a6ca7b636",
"logx/rotation.go": "e278c5ffbdba36c39b15c8b31299abe402695edaf525eb5b03d4328cf35fce61",
"internal/configdiag/error.go": "a25f9f398cd0e5ea1a0d049c8a7c264c85be67c469619b41220cf56afffdbda6",
"database/mysql/mysql.go": "e5f95b97dd79eb64d493255d2fd2f5895538131021eb8a6068de8ff6f484702c",
"database/connector.go": "5a973ea4bc53fee2b72f1925f5cf4e3d83a3c935a1455406f4a7abd46c47c007",
"database/database.go": "5cf05972b56870d89da5290e3b0db777b0253c3b4214cee3d47ab7fa6d76a263",
"examples/developerTools/config.yaml": "1e672ba1b0b16a417840a09468f785776c51408ee973b55b1910cb736a8e29d9",
"examples/developerTools/README.md": "3ba0d700dc3e3788a2a57367273b8d6b0f2acbf2f6cf38149edeef84ce898908",
"examples/developerTools/main.go": "a799d3106b4a64258b7c1cbad9c776cab840526795416b07b114628e3bd4c440",
"cors/cors.go": "a0feb191b5ecc43b0a0c889af5c1ec960fdc55bfa813aff49413008077e64f70",
"cors/config.go": "973300087dee87fd35bb56a2c40b6ace75f85b08e6749cc509421aab9135e2e4",
"cors/cors_test.go": "49b20eb72ba4c40b949a711fba0bdcd342d793a8a010771204e696c563b3e08c",
"app/cors_test.go": "7dcfcbb9c605a16bbd5455177a1f527dbb18f673e6ebb2b93d7b874c64c5d56a",
"cmd/go-omni/apigen/corsConsumer_test.go": "8b5ce07474f50d89ba1d7d155b433e7103785b350db13df8cacd527b7070e240",
"cmd/go-omni/apigen/testdata/cors/consumer_test.go": "677c559b8c6fc77aa6d8a69c0c1296f1fbefaa7f2f64cf80609dbe4027e254ab",
"examples/cors/main.go": "39d76c4e2a4929f18f2acbfe03ab10c50b7276e56345de4d12eb962b5f6cc5b7",
"examples/cors/config.yaml": "a78f83433810cb385ac9a8debbeb6932ebef71d85df073afbfc1d928fb7bf89f",
"scripts/corsBrowser/main.go": "2654778e9c32829f6dadefe572551f9feb891da110741a08bf9243c7284c3ba2",
"database/transaction.go": "037f14ed495e6d024e13e2033c2e469b138dc5c1e1d6f6a9a73e723e094e71e1",
"database/transaction_test.go": "b8fb122c145836c07ca062dc8c89c07ae7482885d33c3f2c239cca21e97b72d4",
"database/transactionResponse_test.go": "bc59b17605c5b5e051fc987caf6dafbbb5d71f4b1380a5ceec8f2dafaafb6af7",
"database/mysqlConnector_test.go": "db5a9f5a99fd46e8e84c4452663704172ddc26a57ad6af84d3165041ff5357e9",
"examples/transactions/main.go": "ac2a17556b63c0bbb8a5c726381b6929df235da089549937a70d3696e2cba3f2",
"examples/transactions/demoDriver.go": "950b5f9bf0d20b8b2bc7590d502e110f2815fc394df789cac39e324149870ab6",
"i18n/bundle.go": "cd0b54e779fa9f8e3a1b5d4c7456ae06f0f39618365362de329d4b61104abb93",
"i18n/types.go": "cc912f7c7dc598de758cec79568fd0bdfac6cab8a8333a6ce0570680236d415a",
"i18n/plural_test.go": "ad3a430f370bc51503aa5bce335dcb5962f6e8226f2925956ddd419fd9c230d8",
"i18n/export.go": "99608af1d487fced1bba49b77157e7a1f51afb4341fbb607f09920dfca94155a",
"i18n/pattern.go": "1c7a91e48f449124d2de90abebd698401aef93896fcdfcf24a83e12a048c5192",
"i18n/README.md": "11a933abe712c961ca1c3b138d4440bc440a7b4c6e30654849e604efe4be056c",
"i18n/plural.go": "9b69e23568a4ea240d5d4bd2c62237932245a8a91aa451386f4a7d9d76d774f9",
"i18n/load.go": "6e43d781ba90db6de78d2138e496161c391797a735211cae7342abebac2c5618",
"i18n/bundle_test.go": "f9e30d36955722f51e7d9dfe685515441451459547a3235b97064de7dda4d8f9",
"i18n/i18ntool/extract_test.go": "dd62077d190e4264348f18e8f86d25e324eebf2d471918f27da8f0c552bf46c6",
"i18n/i18ntool/extract.go": "f0ab49435f61750b06ee8105ff016be5fe06c8404f90163708d9f7e423ae89c0",
"i18n/errori18n/errors_test.go": "2c2b74b1752d4367035c9f6b9f0f79bd05dac583f37f4e4de97d2163e667be4d",
"i18n/errori18n/errors.go": "539a9eac6b841a566bc808363e9505226c7510d5dc277da621dbefb16cb06ecd",
"i18n/browser/runtime.d.mts": "fd2f18dbb6d1b1dfe2392a5e0831354f0fb2ac8815328d8b422ab79503889ad7",
"i18n/browser/runtime.test.mjs": "83b219f3b72cdeb417f6223f9f0942ef8d3b43d5051b1c66dc475c7634d65acf",
"i18n/browser/README.md": "8a3de821c6cdf57c99ee2b56fa0deeb7557b9d11a9a77bf05b00c1ae22bf34ab",
"i18n/browser/conformance.mjs": "95b7325ef24513b53eb5b8d3475fe3c5c7c56bc2da2cb1aa74e097b2ee410623",
"i18n/browser/assets.go": "a20c679902c5d96dba13b0df31ef98e3516f0ebdd1c403740a6a720a412b0b84",
"i18n/browser/runtime.mjs": "a38f6378a835661ee9198ff910eafd04f4e56051c113d0637703e59e4ae3cd3c",
"i18n/browser/testdata/consumer.mts": "84e3e26d2d86a03b30e3fcd27c3bb51c268ac89ef115f8def274321250b46e08",
"i18n/cldr/LICENSE": "220ba0e1c43b99530d2d5bdb892a99dca0989414f51ab695ecd90163eaa1ec3b",
"i18n/cldr/README.md": "cd5aef06e415cf21913a5b8736c29abbc02df449868c16cd08be8aa7e0c8fea0",
"i18n/cldr/plurals.json": "ce19668400e9f7904e8b3939d1f0a2872fe8da7bdea9f45f5d50b3a2bac3b382",
"i18n/httpi18n/http_test.go": "1f0266d2d2a54bfe767efe76bfedce6130f6c988dd04be801416336a95a5d606",
"i18n/httpi18n/http.go": "ac22eeb795b62b74d0b9a233b6fb79a337f35d430e6a136fb31f643ee4512f0d",
"examples/i18n/index.html": "7d0c9e1fdaf96c883cbe8e671ae094d83c2742a9f76b820649d58d62f4944c12",
"examples/i18n/config.json": "c3e12efcedbfc6a8ca73cae9cd4cc3617ee363af8747aec630808e6f5e1eb682",
"examples/i18n/README.md": "bf5c3ae49b921b1c25d06e19d5ecb743f349ea9d085b9d9da79f1391a29fa9e1",
"examples/i18n/main.go": "3f8599781e423a9e3a968f5310fcc80d2daf5f09b733050c1e13c4fea589f29e",
"examples/i18n/main_test.go": "c99f736841fe579d2134762d4962da73d6ab88090e5921ce8c26edf5134bee9b",
"examples/i18n/locales/ui.en.json": "fa8b210ad1a78baae8f444420c983c5498025a1a3ea0bd15d64d8125629bee42",
"examples/i18n/locales/ui.zh-Hans.json": "04761003bea2a02b1e746a1f458f50fa2e0fdb3706d02bc5cb5a4f16378c5021",
"examples/i18n/locales/errors.zh-Hans.json": "d9113b720f4c284b90c41a6db4a867d272db4e951cd284eeb00ca69e3fc21385",
"app/i18n_test.go": "4d6125b78c33c7df759584b84f791d9a041673d6536503c6671a71e20b588d39",
"errors/localization.go": "a047c33e9c4ad9ca10aacfa0800dec51a5db788c1299fba0fb9b361abef41c85",
"cmd/go-omni/i18n.go": "be95a89de3fcc34baaea76669f30616902b0f5d7d6ef018e9c29a8e38106fb6a",
"cmd/go-omni/apigen/i18nVersion.go": "c4187e5d77164b8e906294b63e8d351db49a6d587b4f77a32529f6a4b75cb1c9",
"cmd/go-omni/apigen/testdata/i18n/consumer_test.go": "b62ad30f5912c50e1f6bb1737fd75430ead113989990acb6dcdc93f5d88de0af",
"contract/policy_test.go": "d1ea81c923dda58ae75323cc1eb9773517e63180153dbdf19eb35a634c9890db",
"app/body.go": "4064b96fc38c5b75ec17f0d82e0dec79242640c4d0903bf544e4fda057e5397a",
"app/router.go": "b0418e18fd546b855a4cce9ff19efc25b274d9b9c52144e12338bb5d97a5be9f",
"app/requests.go": "c4cb453e8215ed594e6755b256b4b1bbcd3b870584f4be19480d4dd1b9e0aa3d",
"app/bodyPolicy_test.go": "505afa7bcc3f70009ef2af2c455e377277db01c2b883c8e5204de0cbebf25cbb",
"app/requests_test.go": "b008b5599e1f5eea01a08f15220588bd581a2d5e98658d53f0acb273dd946137",
"app/transport_test.go": "10f086eeb77e3b11e83768bbbb0372aa68056a8c8839a18edfaf597b5acff689",
"httpx/context.go": "ba5b38ac7f0308d1c8e922426a5f2526e03a3c2f8164da80117ea5f8729c2ef9",
"internal/httplimits/limits.go": "98ba40ceda78fcf4b08d51b0851de851329f568fd35b0a4aedefd4b48a43e780",
"lifecycle/runtime_test.go": "ccd302f78535215bea69d7c8ad8c812c79c52cca087353358206cb6b5926d34b",
"transport/http2/server.go": "e0b7fdb3069583d042df5117b36a3fffc423ee452b0a9da162e3604fcce80b49",
"transport/http2/lifecycle_test.go": "2a298b8e466ff4145105338904ec5d9e19087c91d829d6ae5cad8c8b1ee05b15",
"transport/native/server.go": "8c2fc503bc157265838b453bfe289ec68eeb2bdaf63f6dd1e585536a8278ba0c",
"transport/native/earlyReject_test.go": "0fd598e24199beb55bead10d741d739f7361345584c4430b4a97c35d59d62774",
"examples/httpSafety/main.go": "4651b3806e7a8030101ba6a70794fcc18a50d2939fe0a59d7f80bb4a7de30f15",
"storage/upload.go": "9ff2930ba389163acc8063b51107c09a60290609ba384de8004c498ce09f20a1",
"storage/read_test.go": "9b11ab9640e98c1c9fa7ce1e2587df217e874e3a6a6e27a0dd0ade6fbf3d5e1a",
"storage/signedUpload.go": "a8d31bcbbad64796f04611e69271e94d917c9bcd5aeabd7a97ea155b0093c814",
"storage/multipart_test.go": "4053f680f7795db2a6b4aa23590b3669eec7a609dd92c4a1e20891a5299274ee",
"storage/multipart.go": "3b718ebe6da681c89a3f797892c88e7acb15c295df2376d64ac35da0976ed69d",
"storage/signed_test.go": "eaffd40894018814e976477f96cf0fbaecfa1987caad78830d9fc8e96995882f",
"storage/upload_test.go": "12b60419807ed64b89e30861674e19abee7b0b7bb0e8ec9ac5b3aa2e0710a1da",
"storage/storage.go": "cb670872f01cf1d7b4ebf21cec9fc6f52ad93bfdf523b2f28bbb8d9c7fe39a6c",
"storage/signed.go": "ae4b144137627be35718c40743ebc2f063ec667c24b6d8984ccfcca034a73787",
"storage/read.go": "92787c74ae024822f82b5b84bffc9f8a355f1f2565b99ca7fbefe49157ef4851",
"storage/s3/upload.go": "3ce0b3626249ffe7a041606d6a9481743af8a0d4868fadea20ed3ed44ee0c765",
"storage/s3/read_test.go": "9b64fda5c83ba41508ec23cf41aeeb9826b677caaa851dcbeb04abe1f43eed65",
"storage/s3/config.go": "626ec6a2c0b06c95ee7eb7aa0832b190cfcdeb8a127b6b89e99204040bc3b041",
"storage/s3/upload_test.go": "a41fdbd100d9c79a3606c5942f98017afe26d841b251d96e979df87d0af93867",
"storage/s3/read.go": "319b57f2ecf435b71085c7cdcafd50a1e30caa5f357f83d4db3cdcef085792b6",
"storage/s3/s3_test.go": "9ef284926ec9936ddfec8c52a9bf04fc7b4be55c43a535f6c5a428d1535f6fec",
"storage/s3/s3.go": "016bef5948e97f3d043d2e858716f1c02d9f27c4e02802bdc1be3860116f4b6c",
"storage/local/read_test.go": "ef1f7feb0887ea4b1f30304dfaf542fe9788b6e17856c942f1cd98448656194f",
"storage/local/local_test.go": "3a2241840968df8e37fd67169c3ef708228449e81f65aff908c8b60d49434dc0",
"storage/local/local.go": "5d1ca75d0d2ca87981eef84a60bf98d0feefa2a9594940d6bd231877f4f8ec02",
"storage/oss/upload.go": "556129611f9e5549cac8a36b4026bfb19c112a295b55192579f9ac116e20626f",
"storage/oss/read_test.go": "a1c273cfedd2f3ee31ce407a7ddd6fd5ad02c65ad632a91bc7f3ead3712b5bbd",
"storage/oss/oss_test.go": "555a954010b1218804dc7951fc75e42eae24eba9a99c0871f5ba787f3fc87d45",
"storage/oss/upload_test.go": "08eb0ed157f3743288d781f61d4d2b7bfe77852a8259c5e598c719d35889409e",
"storage/oss/oss.go": "4216ce9f3fe3ac5418a5b809db3cef7e99dcca72a25a2a12301f5897cda85dd5",
"storage/oss/read.go": "429a9a9f6c586a885d7cccb76c9d572d53a79df637ff128caacb2eac35b8d862",
"storage/storagetest/contract.go": "e7ad7242102437882269d4066462059d7974586b36a82d18639eca10fd932be9",
"storage/storagetest/uploads.go": "9b66f86dd94ccdf30c7d603d432bc2a55956608898e46c1274fa27d16fa1a969",
"examples/signedRead/main.go": "f921a3c5fbcbfa84291c0c95a9c2c211319a8f0537acc5ec640238f251e652e1",
"examples/signedRead/main_test.go": "6ab559c442b2aa355231efe0342b9ec3617bf97bb1f6dfd7ad304b9a452e4fc4",
"telemetry/errorCatalog_test.go": "eb4f487e226dfb45f179dbe13b5eba5a23a478b3417eea7a9710230ce71f9ad5",
"telemetry/telemetry_test.go": "a44531e5c9efc69d99bf3fd73bafc904bc5dae0f886676cdcf0bda30552e4b3b",
"telemetry/telemetry.go": "043f295a62bef4d4a92b8918f6d201ac54a15afd6823a01d64ac0656c2bede5d",
"telemetry/adapters.go": "a892ef0c2b268caf266a3ea0f833ecf7e9228ff59aeaeecae6fe4a2170ba51e2",
"telemetry/headers.go": "a65f275a78e69bfe0b9c91bb7d4947609891ea34b6b7377e436b26b53f6ef74a",
"examples/telemetryHeaders/main.go": "f331d9cf2beb45d95fe1c3ba3e5d720e99b16e76511fe7d2c54bbcae97202592",
"examples/telemetryHeaders/main_test.go": "abe3a6d1d7f3348d0802dacc25f8fb267e592819949e567991be6dbd19783d88"
}
}

View File

@@ -0,0 +1,74 @@
{
"passed": true,
"frameworkVersion": "v0.31.0-preview",
"frameworkSum": "h1:d0sf951iJwMUtHj29D6BjUwOgg+C79GwQ+2vjAHLjDo=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 22.77,
"tests": [
"TestConflictRollsBackEarlierWrites",
"TestUnsafePaths",
"TestGeneratedProgramConsumer",
"TestOwnershipAndDrift",
"TestRecordedTemplateUpgrade",
"TestProjectGuards/module",
"TestProjectGuards/oldFramework",
"TestProjectGuards/manifest",
"TestProjectGuards/lock",
"TestProjectGuards/otherGenerator",
"TestProjectGuards/symlink",
"TestProjectGuards"
],
"checks": [
"remoteCLIInstall",
"installedVersionDefault",
"generatedConsumerBuildRaceVet",
"defaultNoAPIFiles",
"defaultNoNetworkListener",
"finiteProgram",
"SIGINT",
"SIGTERM",
"initializationFailureAndPanic",
"runErrorPanicGoexit",
"fatalComponentFailure",
"nonzeroProcessFailureAndTimeoutExits",
"partialStartupDraining",
"acknowledgementBeforeResourceClose",
"workContextSurvivesGracefulStop",
"cancellationGrace",
"unresponsiveRunAndBackgroundWork",
"retainedResourcesAndLogger",
"explicitConfigurationOnly",
"checkConfigWithoutDependencies",
"handwrittenFilesPreserved",
"generatedFileConflict",
"missingGeneratedFileRestored",
"missingHandwrittenFileNotRecreated",
"readOnlyCheckAndDryRun",
"templateDriftUpgrade",
"manifestIdentityAndVersionGuards",
"symlinkAndLockProtection",
"fileTransactionRollback",
"publishedExampleFiniteRun"
],
"sourceHashes": {
"cmd/go-omni/internal/genfs/files.go": "97e338c74ddb3145eb0ec942b69d5c21c83dfb2f9f2165376fc83366ae1e93af",
"cmd/go-omni/internal/genfs/files_test.go": "a659b31ddf195c80d48cfc00302a3f4c6d5a1ed68b58d241da03a44ad54fe34a",
"cmd/go-omni/project.go": "07e362664053e59dbb8f8f46d4c7be9d66afb93a7142a810109b66cd296ae8a6",
"cmd/go-omni/projectgen/consumer_test.go": "312442d56599c8264f14aef03722a221a72e70c038875f11c3dbfc19e9872883",
"cmd/go-omni/projectgen/generate.go": "d2ad08bc857a692aa79f05b9a14e2c0a21e2dfed84f22e4b89f353ca0168a260",
"cmd/go-omni/projectgen/generate_test.go": "f113258f0150a9f569dbe422b9f40e8ba0db3d115caf09a2c81432a8ae126e03",
"cmd/go-omni/projectgen/templates/.gitignore.tmpl": "82abf6aa84ec1c3ec218b09b5ce465af616c4fe8ff6de6db09abef962a137401",
"cmd/go-omni/projectgen/templates/README.md.tmpl": "3d01821fb4af9e97ca92f47e80a520d6f64ef136e49a8bcd4fed53dec32bde23",
"cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl": "c224c68f50ea00a10e9dfd317347ac843c9107738861dab7824f7a7b1ae3a1ad",
"cmd/go-omni/projectgen/templates/internal_config_config.go.tmpl": "55111dbe02f0b91ed58a36449dcee8c9d7338251730764cda0de3e77e4a82f28",
"cmd/go-omni/projectgen/templates/internal_config_loadGen.go.tmpl": "95fb848065e3e516a8026f4439ede6d8be100e1726a56a124a6e49cb1d9d817d",
"cmd/go-omni/projectgen/templates/internal_config_loader.go.tmpl": "c9ad9f26a7d84622d7f2114a020b57ba6871a86ccf6cbc015c63674026ce81c3",
"cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl": "51c2b4b407aa8b350e666be49b9d888ee42070627e28303ae2e1b0c9b4d73c95",
"cmd/go-omni/projectgen/templates/internal_svc_runtimeGen.go.tmpl": "8e259634ac7fbac0d2a13629864b2a5c89e43b6e9c477db9272b00cf49dc47d1",
"cmd/go-omni/projectgen/templates/internal_svc_serviceContext.go.tmpl": "1ed62fd86fd050468cac1c158a438deed01d4c25b4b634eec4ccd84b24df476f",
"cmd/go-omni/projectgen/templates/main.go.tmpl": "c59a7773f00c31c1a67e6a0093de26bc6f2dc8dbe34c61c1fa7e1b341e4afcc2",
"cmd/go-omni/projectgen/testdata/consumer_test.go": "38df45a69e843dfe6897d0296337e10b507076ea3c7a32901c1cad340915b0ee",
"scripts/verifyProgramRelease.py": "9127743ee929cb32701e4a063372a817ead3fd2795c2722b75f2a0c9123fa85a"
}
}

View File

@@ -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

View File

@@ -0,0 +1,104 @@
{
"passed": true,
"frameworkVersion": "v0.31.0-preview",
"frameworkSum": "h1:d0sf951iJwMUtHj29D6BjUwOgg+C79GwQ+2vjAHLjDo=",
"toolchain": "go version go1.25.0 darwin/arm64",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 25.68,
"packages": [
"git.yuntuo.hk/k2safe/go-omni/examples/websocketRPC",
"git.yuntuo.hk/k2safe/go-omni/ws",
"git.yuntuo.hk/k2safe/go-omni/ws/hub",
"git.yuntuo.hk/k2safe/go-omni/ws/observe",
"git.yuntuo.hk/k2safe/go-omni/ws/omni",
"git.yuntuo.hk/k2safe/go-omni/ws/rpc"
],
"tests": [
"git.yuntuo.hk/k2safe/go-omni/examples/websocketRPC:TestBidirectionalRPCAndRestore",
"git.yuntuo.hk/k2safe/go-omni/ws/hub:TestHubConcurrentLifecycle",
"git.yuntuo.hk/k2safe/go-omni/ws/hub:TestHubLimitsAndReusedID",
"git.yuntuo.hk/k2safe/go-omni/ws/hub:TestHubRealSessionDeliveryAndDisconnect",
"git.yuntuo.hk/k2safe/go-omni/ws/hub:TestHubRoutingBackpressureAndCleanup",
"git.yuntuo.hk/k2safe/go-omni/ws/observe:TestObserverCloseCallbackBoundaryAndPanic",
"git.yuntuo.hk/k2safe/go-omni/ws/observe:TestObserverCloseFieldsNeverExpandsErrors",
"git.yuntuo.hk/k2safe/go-omni/ws/observe:TestObserverConcurrentSessionsAndTotals",
"git.yuntuo.hk/k2safe/go-omni/ws/observe:TestObserverPayloadFreeLogsAndIndependentDurations",
"git.yuntuo.hk/k2safe/go-omni/ws/observe:TestObserverQueueSnapshotsAndSampledPeaks",
"git.yuntuo.hk/k2safe/go-omni/ws/omni:TestAppRetainsResourcesForBlockedComponent",
"git.yuntuo.hk/k2safe/go-omni/ws/omni:TestMountLifetimeAndStatus",
"git.yuntuo.hk/k2safe/go-omni/ws/omni:TestMountRejectsNative",
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:FuzzProtocolDecode",
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestBidirectionalNestedCalls",
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestCancellationBusyAndCompletionBarrier",
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestDuplicateIncomingIDRejected",
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestErrorsDoNotExposeImplementation",
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestEventErrorObservation",
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestPendingLimitAndDisconnect",
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestRealTCPRPCAndShutdown",
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestReplyLateDuplicateAndProtocolLimits",
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestResultEncodingPanicAndOversize",
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestSubscriptionsRestoreAndRemoval",
"git.yuntuo.hk/k2safe/go-omni/ws:FuzzOrigin",
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientCloseCancelsHandshakeAndBackoff",
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientConcurrentDialLimitAndClose",
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientDoesNotReconnectAuthenticationOrProtocolFailure",
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientElapsedBudgetInterruptsStalledHandshake",
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientHTTPProxyAuthenticationAndTunnel",
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientHandshakeDeadlineDoesNotExpireSession",
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientHandshakeStats",
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientHeadersRefreshReconnectAndFreshSessions",
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientReconnectsRemoteGoingAway",
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientRetryBudgetAndValidation",
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientSingleSlotRapidReconnect",
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientSubprotocol",
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientTLSAndMutualTLS",
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerAllowedOriginsAndAuthentication",
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerConcurrentShutdown",
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerConfigValidation",
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerHandshakeRateAndPanic",
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerLimitsAndShutdownCallbacks",
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerOriginAndProtocolPolicy",
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerParallelConnections",
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerStopDuringHandshake",
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerTypedNilRejection",
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerUnsupportedWriter",
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionBoundedDispatcherDoesNotBlockPing",
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionCallbackPanicAndCloseBarrier",
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionCanceledQueueDoesNotCloseConnection",
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionCompressedReadLimitAndInvalidUTF8",
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionConcurrentSendAndJSON",
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionConfigUpperBounds",
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionEchoAndCompletion",
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionHeartbeatDetectsUnresponsivePeer",
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionInboundQueueFullCancelsCallback",
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionQueueBoundsCopiesAndGracefulDrain",
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionValidationAndRedactedErrors",
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionWriteCancellationStopsConnection"
],
"checks": [
"remoteCLIInstall",
"installedVersionDefault",
"fixedRemoteModuleChecksum",
"downloadedWebsocketRaceTests",
"remoteEchoAndGroupExample",
"remoteBidirectionalRPCExample",
"remoteTwoConnectionSubscriptionRestore",
"generatedCustomRoutesConsumer",
"oldServiceWithoutHook",
"handwrittenHookPreserved",
"customRouteFailureCleanup"
],
"examples": {
"websocket": "hello websocket\nhello group",
"websocketRPC": "connection 1: double=42, remote callback=\"generic Go client\", restored=1, event=1\nconnection 2: double=42, remote callback=\"generic Go client\", restored=1, event=2"
},
"sourceHashes": {
"scripts/verifyWebsocketRelease.py": "f87e2981fb352b8e5d494afc2d0b496bed5949af05e7b03a5340c8fc1901d56a",
"cmd/go-omni/apigen/customRoutes_test.go": "3250e489326faa5a2cf60be4fb97f75d2e2666baef726b30f72464898c057f0d"
},
"limitations": [
"Functional release acceptance; not a vulnerability scan or browser UI test.",
"Known framework HTTP/2 dependency vulnerabilities remain documented in the validation report."
]
}

View File

@@ -0,0 +1,109 @@
# 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 客户端测试代替这一项。示例源文件可供手动运行,临时服务已停止。
## 远程发布验收
发布标签 `v0.31.0-preview` 固定在提交 `3272974bc1cd19c79efe7354b00b6ed67f27f51e`,已核对远程标签解引用结果。CLI 默认版本与框架版本一致,发布前再次通过全库 `go test ./...` 和 managedService 生成漂移检查。报告随后独立提交,不移动发布标签。
四组验收各自使用空 `GOMODCACHE`、临时 `GOBIN`、`GOWORK=off` 和 Go 1.25.0,从远程安装固定版本 CLI;消费项目均无本地 replace。四组校验和一致:`h1:d0sf951iJwMUtHj29D6BjUwOgg+C79GwQ+2vjAHLjDo=`。
| 验收 | 结果 | 报告 |
| --- | --- | --- |
| WS 发布包 | 6 个包、60 个顶层测试通过 race;11 项检查,含两个可运行示例、两代连接订阅恢复、生成路由与失败清理 | [WS 远程验收](websocket-remote.json) |
| API 受管工程 | 110 项检查,含 11 组生成消费者与旧能力回归 | [API 远程验收](websocket-managed-remote.json) |
| 通用工程 | 30 项检查 | [通用工程远程验收](websocket-program-remote.json) |
| 混合工作区 | 16 项检查 | [工作区远程验收](websocket-workspace-remote.json) |
复验命令(在源码仓库根目录,PATH 选择所需 Go 工具链):
```sh
python3 scripts/verifyWebsocketRelease.py --version v0.31.0-preview --report reports/websocket-remote.json
python3 scripts/verifyManagedRelease.py --version v0.31.0-preview --report reports/websocket-managed-remote.json
python3 scripts/verifyProgramRelease.py --version v0.31.0-preview --report reports/websocket-program-remote.json
python3 scripts/verifyWorkspaceRelease.py --version v0.31.0-preview --report reports/websocket-workspace-remote.json
```
这些结果确认发布内容的功能与消费可用性,不改变下文的工具链、HTTP/2 依赖安全结论和浏览器实测缺口。
## 功能与并发验证
使用 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)。

View File

@@ -0,0 +1 @@
No vulnerabilities found.

View File

@@ -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"
}
]
}

View File

@@ -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.

View File

@@ -0,0 +1,34 @@
{
"passed": true,
"frameworkVersion": "v0.31.0-preview",
"frameworkSum": "h1:d0sf951iJwMUtHj29D6BjUwOgg+C79GwQ+2vjAHLjDo=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 17.97,
"checks": [
"remoteCLIInstall",
"remoteAPIModule",
"remoteGenericModule",
"explicitKinds",
"manifestInferredKinds",
"mixedFormatWithProjectSkip",
"mixedGeneration",
"mixedCheck",
"nativeGoWorkBuild",
"nativeGoWorkRaceTests",
"moduleAndWorkspaceMetadataPreserved",
"bothProjectFailuresAggregated",
"nonzeroFailureExit",
"failedCheckIsReadOnly",
"crossProjectPreflight",
"missingGeneratedOutputRestored"
],
"sourceHashes": {
"cmd/go-omni/apigen/workspace.go": "cc39732ed9a41bb2dfa14933a830a7f10e8e3b2edc2ada07c376a738c0ec77e0",
"cmd/go-omni/apigen/workspaceMixed_test.go": "119f2a2aadc2b3ee2471bed1309c6ad8e36488b258f2ab1ba5ba5bab4219643f",
"cmd/go-omni/apigen/workspaceCatalog_test.go": "372874322aece46714351f088b5741642fe4e63d95062743a575958fa58b8b39",
"cmd/go-omni/workspace.go": "b7bbd3d9d58b3987090edf7e3714d7d8396e7c332174022d918a52e16396397d",
"cmd/go-omni/workspace_test.go": "0170d18158325262765d8b3067f55ca3ce9fe994f2d030cafee3e82b13bfcd4b",
"scripts/verifyWorkspaceRelease.py": "0495dc97c68ac17a7969088cf94b1cbbfa6e53f18fb36147c64bc34edb260bca"
}
}

View File

@@ -165,7 +165,12 @@ def main():
# optional adapter dependencies before testing their downloaded packages;
# lazy module loading otherwise leaves their sums out of starter/go.sum.
run(["go", "get", MODULE + "/storage/oss@" + args.version,
MODULE + "/storage/s3@" + args.version], starter, env)
MODULE + "/storage/s3@" + args.version,
MODULE + "/telemetry@" + args.version], starter, env)
typescript_available = shutil.which("tsc", path=env.get("PATH")) is not None
i18n_tests = {"TestIndependentImmutableBundlesAndFallback", "TestResourceAndParameterDiagnostics", "TestStrictLoadingAndExplicitOverlay", "TestPinnedCLDRSamples", "TestBrowserConformance", "TestSourceLocaleCanBeFallbackOnly"}
if typescript_available:
i18n_tests.add("TestTypeScriptConsumer")
for package, expected in {
"storage": {"TestReadOptions", "TestReadSignatureAndExpiry", "TestReadRangeStreamingAndCache"},
"storage/local": {"TestSignedPayloadRanges"},
@@ -184,7 +189,7 @@ def main():
"transport/http2": {"TestShutdownWaitsForHandlerAfterClientDisconnect"},
"transport/native": {"TestApplicationRejectsUnreadBodyPromptly", "TestBodyRejectionWithBusinessHTTP200DoesNotDrain"},
"contract": {"TestBodyPolicyPropagatesOuterLimit", "TestJSONClosingDelimiterPreservesReaderCause"},
"i18n": {"TestIndependentImmutableBundlesAndFallback", "TestResourceAndParameterDiagnostics", "TestStrictLoadingAndExplicitOverlay", "TestPinnedCLDRSamples", "TestBrowserConformance", "TestSourceLocaleCanBeFallbackOnly", "TestTypeScriptConsumer"},
"i18n": i18n_tests,
"i18n/httpi18n": {"TestNegotiationAndExplicitResolvers"},
"i18n/errori18n": {"TestCatalogAdapterIsExplicitAndDetached"},
"database": {"TestInTxStagesAndCauses", "TestInTxCommitOutcomeUnknown", "TestInTxCancellationAndOwnership", "TestInTxCancellationRace", "TestInTxSuccessPanicAndConcurrency", "TestInTxPublicErrorCompatibility"},
@@ -192,7 +197,7 @@ def main():
output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/" + package], starter, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
passed = {event.get("Test") for event in events if event.get("Action") == "pass"}
assert expected <= passed
assert expected <= passed, f"{package} missing tests: {sorted(expected - passed)}"
signed_read_output = run(["go", "run", MODULE + "/examples/signedRead", "-check"], starter, env)
assert "PDF preview, Chinese filename, download, HEAD and Range passed" in signed_read_output
checks += ["remoteSignedReadOptionsAndLegacyLinks", "remoteReadSignatureTamperingAndExpiry",
@@ -237,9 +242,10 @@ def main():
browser_output = run(["node", "--test", downloaded / "i18n/browser/runtime.test.mjs"], starter, env)
assert "# fail 0" in browser_output
checks += ["remoteI18nRuntimeRace", "remoteI18nGeneratedConsumer", "remoteI18nHTTPBusinessInvariants",
"remoteI18nSourceOnlyFallback", "remoteCLDRSamplesAndBrowserConformance", "remoteTypeScriptConsumer",
"remoteI18nSourceOnlyFallback", "remoteCLDRSamplesAndBrowserConformance",
"remoteI18nCLIExtractionCheckExportTypes", "remoteI18nNamespaceIsolation",
"remoteBrowserRaceAndFailureRetention", "remoteJSONReaderCausePreservation"]
checks.append("remoteTypeScriptConsumer" if typescript_available else "remoteTypeScriptConsumerSkippedWithoutTSC")
checks += ["flatFieldComposition", "copyxThreeEndpointConsumer", "explicitNullAndMoneyPolicies", "fieldAdditionWithoutCopyRuleChanges", "middlewareFactoryInjectionAndCleanup", "optionalRepositoryPreservesBusiness"]

View File

@@ -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()

149
ws/README.md Normal file
View File

@@ -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 由业务协议显式传播。

633
ws/client.go Normal file
View File

@@ -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 &copyURL, 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}
}

494
ws/client_test.go Normal file
View File

@@ -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, "")
}

83
ws/config.go Normal file
View File

@@ -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
}

48
ws/errors.go Normal file
View File

@@ -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 }

319
ws/hub/hub.go Normal file
View File

@@ -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
}

271
ws/hub/hub_test.go Normal file
View File

@@ -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")
}
}
})
}
}

100
ws/limits_test.go Normal file
View File

@@ -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)
}
}

44
ws/observe/README.md Normal file
View File

@@ -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
```

194
ws/observe/observe.go Normal file
View File

@@ -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
}

300
ws/observe/observe_test.go Normal file
View File

@@ -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)
}
}

44
ws/omni/handler.go Normal file
View File

@@ -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
}

92
ws/omni/handler_test.go Normal file
View File

@@ -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)
}
}

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

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

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

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

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

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

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

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

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

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

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

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

405
ws/server.go Normal file
View File

@@ -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}
}

326
ws/server_test.go Normal file
View File

@@ -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)
}
}
})
}
}

568
ws/session.go Normal file
View File

@@ -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)
}

392
ws/session_test.go Normal file
View File

@@ -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
}

View File

@@ -0,0 +1,2 @@
go test fuzz v1
string("https://::443")