Skip to content

feat: [EPIC-GW-05] 会话路由与流式透传中继 (Gateway 核心能力补全)#320

Merged
phantom5099 merged 8 commits into
1024XEngineer:mainfrom
pionxe:feat/gateway-epic-gw-05-session-stream-relay
Apr 16, 2026
Merged

feat: [EPIC-GW-05] 会话路由与流式透传中继 (Gateway 核心能力补全)#320
phantom5099 merged 8 commits into
1024XEngineer:mainfrom
pionxe:feat/gateway-epic-gw-05-session-stream-relay

Conversation

@pionxe

@pionxe pionxe commented Apr 16, 2026

Copy link
Copy Markdown
Collaborator

🎯 目标 (Motivation & Context)

本 PR 正式交付了网关层的终极核心基建:[EPIC-GW-05] 会话路由与流式中继。 在此之前,NeoCode 网关仅能处理“一问一答”的同步请求。本次重构彻底打通了从底层 Runtime Event 到前端连接(IPC/WS/SSE)的异步流式管道,使得大模型“打字机”实时输出、Agent 执行状态推送等复杂交互成为可能。

💡 核心架构决策:

  • 协议大一统:全端点(本地管道、WebSockets、SSE)同等支持流式收发。
  • 通知形态标准:严格遵守 JSON-RPC 2.0,服务端流式下发采用无 id 的 Notification (gateway.event) 形态。
  • 动态状态机:采用了“显式订阅 (gateway.bindStream) + 隐式无感续期 (gateway.ping)”的智能生存策略,并结合 15 分钟 TTL 淘汰机制。

Closes #318

✨ 主要变更 (Key Changes)

1. 核心中继器 (stream_relay.go)

  • 实现了高并发安全的 Publish-Subscribe 内存路由表。
  • 高可用机制:引入了 有界发送缓冲 (Bounded Queue)慢消费者剔除 (Slow Consumer Eviction)。若某个终端接收过慢导致队列溢出,网关将无情切断该连接,确保全局 Event Loop 不被拖死。

2. 身份上下文提取 (connection_context.go, protocol)

  • 建立全链路 ConnectionID 体系,解决 HTTP 这种无状态请求的物理寻址问题。
  • 实现严谨的 SessionID 回溯算法:请求体显式声明 > URL params > 长连接兜底绑定 > 返回 missing_field

3. 多协议适配组装 (server.go, network_server.go)

  • IPC / WS:全面接入双工流式收发机制,复用写锁避免竞态。
  • SSE:专门适配了标准的 event: \n data: \n\n 的协议流格式,完美封装了 JSON-RPC notification。

✅ 验收标准 (Acceptance Criteria)

  • 新增 gateway.bindStream RPC 方法可成功建立会话映射。
  • 长连接下,gateway.ping 及后续带有 session 的请求均可正确触发该会话的 TTL 滑动续期。
  • 断开物理连接(或触发剔除)时,内部路由状态能被正确 GC,不发生协程泄漏。
  • SSE 流式下发格式兼容标准 W3C EventSource。

pionxe added 5 commits April 16, 2026 15:51
为实现统一的流式会话路由奠定基础:
1. 身份注入:新增全局唯一的 ConnectionID 生成器,并注入至底层连接 Context 中,作为会话兜底标识。
2. 规则固化:在 JSON-RPC 解析层实现多级 Session 提取机制(显式字段优先 -> params 提取 -> 连接兜底 -> 缺失报错)。
3. 契约定义:新增 gateway.bindStream 请求体与 gateway.event 通知结构定义。
构建网关内部的核心消息分发枢纽:
1. 会话路由:维护 ConnectionID 到 Session/Run 的强类型多路复用映射表。
2. 自动续期:实现基于 TTL 的滑动窗口生命周期管理,并支持通过 gateway.ping 进行无参无感续期。
3. 容错隔离:为每个连接配置独立的有界发送队列 (Buffer),并实装“慢连接自动剔除 (Slow Consumer Eviction)”机制,防止局部网络阻塞拖垮全局事件循环。
将所有物理入口接入单例中继器,实现外层协议抹平:
1. IPC 接入:实现命名管道/Unix Socket 的连接注册与解绑,复用串行写通道。
2. WS 接入:在 WebSocket 读写循环中接入中继队列,实现双向流式推送。
3. SSE 接入:严格遵循 Server-Sent Events 换行与协议规范,将 gateway.event 格式化输出。
4. 生命周期:在物理连接断开时,自动触发路由表的清理销毁,拒绝幽灵路由。
完成 EPIC-GW-05 的闭环组装:
1. 启动编排:在 Server 初始化阶段注入全局单例 StreamRelay。
2. 分发调度:拦截 gateway.bindStream 方法进行显式路由注册;拦截其余业务请求触发 TTL 自动续期。
3. 优雅关停:保障进程退出时先截断流式中继器,再关停底层网络,实现彻底的无泄漏退出。
作为流式中继功能的收尾,重构核心类型并补安全链路的边缘测试:

1. 类型与校验内聚:整理 types.go 与 validate.go,将流式路由特有的数据结构(如 StreamChannel, BindingHint)与参数校验规则集中管理,提升代码可读性。
2. 核心网络面断言:全面扩展 server_test.go, network_server_test.go 与 bootstrap_test.go,覆盖流式中继器在 IPC/WS/SSE 多端点下的并发注册、事件推送与优雅关停。
3. 协议与分发测试:增补 jsonrpc_test.go 与 rpc_dispatch_test.go,严格测试不同优先级下的 Session ID 提取规则及缺失时的标准 JSON-RPC 报错机制。
4. 边界漏洞封堵:通过 validate_additional_test.go 增加海量非标输入参数的防注入与越界测试。
@pionxe

pionxe commented Apr 16, 2026

Copy link
Copy Markdown
Collaborator Author

/review

@codecov

codecov Bot commented Apr 16, 2026

Copy link
Copy Markdown

fennoai[bot]

This comment was marked as outdated.

@pionxe

pionxe commented Apr 16, 2026

Copy link
Copy Markdown
Collaborator Author

/simplify

fennoai[bot]

This comment was marked as outdated.

@fennoai

fennoai Bot commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Result

Implemented and pushed commit 061e26b to pionxe/feat/gateway-epic-gw-05-session-stream-relay.

What I Changed

  • Fixed relay lifecycle restart safety in [internal/gateway/stream_relay.go] by resetting cleanupStarted/eventPumpStarted when loops exit naturally (via generation guards), so Start() can restart after ctx.Done().
  • Fixed server startup ordering in [internal/gateway/network_server.go] by starting relay loops only after listener setup succeeds.
  • Fixed shutdown cleanup in [internal/gateway/network_server.go] by calling relay.Stop() inside Close() (idempotent path).
  • Added per-connection stream binding cap in [internal/gateway/stream_relay.go] (DefaultStreamMaxBindingsPerConnection=128, configurable via StreamRelayOptions.MaxBindingsPerConnection) to prevent unbounded connectionBindings growth.
  • Added regression tests:
    • [internal/gateway/stream_relay_test.go]: restart-after-context-done + binding-limit test
    • [internal/gateway/network_server_additional_test.go]: relay-not-started-on-listen-failure + close-stops-relay-without-active-server

Comment Triage Outcome

  • Addressed/fixed:
    • relay start flags not reset after loop exits
    • NetworkServer.Serve starting relay before listenFn success
    • NetworkServer.Close not stopping relay
    • unbounded per-connection binding accumulation
  • Not implemented in this patch:
    • session ownership/authorization checks for gateway.bindStream and /sse query binding. Current gateway path lacks an identity/authorization source to verify ownership safely; adding it requires a separate auth design across connection context + routing.

Validation

  • go test ./internal/gateway/...
  • go test ./...

Simplification Pass

Ran a simplifier sub-agent review and applied safe simplification-compatible structure while preserving behavior in this patch scope.

View job run

- start relay loops only after network listener is ready
- stop relay during network server close for deterministic cleanup
- reset relay start flags when loops exit and allow safe restart
- cap per-connection stream bindings to prevent unbounded growth
- add regression tests for lifecycle and limit paths

Generated with [codeagent](https://github.com/qbox/codeagent)
Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com>
@pionxe

pionxe commented Apr 16, 2026

Copy link
Copy Markdown
Collaborator Author

@xgopilot 请你将覆盖率提升至百分之九十五以上

@fennoai

fennoai Bot commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

结果

已补充并提交覆盖率测试,提交到当前 PR 分支:cff5541

已完成

  • 新增测试文件:internal/gateway/coverage_boost_test.go
  • 补充了 bootstrapconnection_contextrpc_dispatchnetwork_serverstream_relay 的大量分支测试
  • 验证通过:go test ./internal/gateway/...go test ./...
  • 已推送到 PR 分支:feat/gateway-epic-gw-05-session-stream-relay

覆盖率

本地可复现口径:

  • go test ./internal/gateway/... -coverprofile=/tmp/gateway.cov
  • go tool cover -func=/tmp/gateway.cov
  • internal/gateway/... 总覆盖率:92.5%(由此前 87.5% 提升)

未完成

  • 按本地 go test 的包总覆盖率口径,尚未达到 95%。
  • 如果你要的是 Codecov patch coverage >95%(而不是包总覆盖率),建议等待本次提交触发 Codecov 后确认;如仍未达标,我会继续按 Codecov 缺口定点补齐。

View job run

Generated with [codeagent](https://github.com/qbox/codeagent)
Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com>
@phantom5099

Copy link
Copy Markdown
Collaborator

/review

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 1 noteworthy issue in the stream relay lifecycle integration.

if s.relay == nil {
s.relay = NewStreamRelay(StreamRelayOptions{Logger: s.logger})
}
s.relay.Start(ctx, runtimePort)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Serve starts relay background loops here, but Server.Close does not stop the relay. If callers use a long-lived context and invoke Close directly, relay cleanup/event goroutines can outlive the server and keep resources/subscriptions alive. Please add a symmetric relay shutdown in Close (or clearly document lifecycle ownership if intentional).

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found one correctness issue in the new stream binding fallback logic. The concern is about deterministic session resolution when a connection has multiple bindings and receives gateway.ping keepalives.

Comment thread internal/gateway/stream_relay.go Outdated
continue
}
state.expireAt = now.Add(r.bindingTTL)
state.lastSeen = now

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RefreshConnectionBindings assigns the same lastSeen timestamp to every binding on each ping. Combined with ResolveFallbackSessionID (which picks the max lastSeen), fallback session selection becomes non-deterministic when a connection has multiple active bindings, because map iteration order is random when timestamps tie. This can route subsequent session-less requests to different sessions across calls.

Consider preserving per-binding recency semantics (e.g., only refresh TTL on ping, not lastSeen), or introduce a deterministic tiebreaker / explicit "current fallback session" field.

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Focused review completed (code quality/performance/security/doc consistency). I found 1 actionable issue in the relay binding lifecycle.

connectionBindingMap = make(map[bindingKey]*bindingState)
r.connectionBindings[normalizedConnectionID] = connectionBindingMap
}
if _, exists := connectionBindingMap[key]; !exists && len(connectionBindingMap) >= r.maxBindings {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxBindings is checked using len(connectionBindingMap) before purging expired bindings. This can reject valid new binds (too many stream bindings) when the map is full of stale entries but cleanup tick hasn’t run yet. In practice this creates false negatives under bursty session churn.

Recommend pruning expired entries for this connection (or counting only active bindings) immediately before the limit check.

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

发现 2 个需要修复的问题,均会在运行期触发行为回归或资源风险。

Comment thread internal/gateway/server.go Outdated
if errors.Is(err, errFrameTooLarge) {
s.logger.Printf("decode frame failed: %v", err)
_ = s.writeRPCResponse(conn, encoder, protocol.NewJSONRPCErrorResponse(
_ = s.writeRPCResponseDirect(conn, encoder, protocol.NewJSONRPCErrorResponse(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里在解析失败分支直接调用 writeRPCResponseDirect 写 socket,绕过了 relay 的单写协程;但同一连接上 relay writer 仍可能并发写(例如运行时事件/队列中已有响应),会破坏“单连接单写”约束,导致帧交错或 data race。建议将错误响应也统一入队,或在 direct write 与 writer 之间增加同一把写锁。

s.relay = NewStreamRelay(StreamRelayOptions{Logger: s.logger})
}
s.relay.Start(ctx, runtimePort)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Serve 在这里启动了 relay.Start(ctx, runtimePort),但 Server.Close 没有对 relay 做对称 Stop。当调用方显式 Closectx 仍未取消时,cleanup/event loop 会继续存活,造成 goroutine 生命周期泄漏。建议在 Close 中补 relay.Stop()(与 NetworkServer.Close 一致)。

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

聚焦主链路正确性后,我保留了 3 个需要修复的问题(并发写一致性与会话路由稳定性)。其余子代理提到但证据不足或重复的问题已去重省略。

Comment thread internal/gateway/server.go Outdated
if errors.Is(err, errFrameTooLarge) {
s.logger.Printf("decode frame failed: %v", err)
_ = s.writeRPCResponse(conn, encoder, protocol.NewJSONRPCErrorResponse(
_ = s.writeRPCResponseDirect(conn, encoder, protocol.NewJSONRPCErrorResponse(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里直接写 connwriteRPCResponseDirect)会绕过 relay 的单写协程。若同一连接上已有队列消息(例如 runtime 事件或其他异步响应)正在写出,会出现并发写同一 socket 的竞争,导致帧交错或写失败。建议统一走 relay 队列,或在此分支前确保 writer 已停并且不会再有并发发送。

connectionBindingMap = make(map[bindingKey]*bindingState)
r.connectionBindings[normalizedConnectionID] = connectionBindingMap
}
if _, exists := connectionBindingMap[key]; !exists && len(connectionBindingMap) >= r.maxBindings {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxBindings 检查发生在清理过期 binding 之前。若连接上存在已过期但尚未被清理的条目,这里会误判为超限并拒绝新绑定(too many stream bindings)。建议在容量判断前先剔除过期项,或容量统计仅计算未过期 binding。

Comment thread internal/gateway/stream_relay.go Outdated
continue
}
state.expireAt = now.Add(r.bindingTTL)
state.lastSeen = now

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RefreshConnectionBindings 给该连接下所有 binding 写入同一个 lastSeen 时间。之后 ResolveFallbackSessionIDlastSeen 选最近会话时,在多个 session 并存场景会退化为 map 迭代顺序(非确定)选择,导致无 session_id 请求的兜底会话不稳定。建议保留原始相对顺序或改为显式记录“当前活跃 session”。

@phantom5099 phantom5099 merged commit e71cf8d into 1024XEngineer:main Apr 16, 2026
2 checks passed
@fennoai fennoai Bot mentioned this pull request Apr 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[EPIC-GW-05] 会话路由与流式透传中继(Gateway 核心能力补全)

3 participants