Skip to content

[BUG][P1] A2A 流取消应中止阻塞的 HTTP 读取并释放连接与工作线程 #277

Closed

Description

@Aias00

已确认问题

A2A streaming 在 boundedElastic 工作线程中使用同步 HttpClient 和 BufferedReader.readLine。代码只在每次读取前检查 sink.isCancelled,没有将取消绑定到在途 HTTP 请求/响应关闭。远端已经建立 SSE 连接但停止发送数据时,消费者取消无法唤醒阻塞读取,try-with-resources 的释放也无法及时执行。

代码:https://github.com/agentic-ai-java/argi/blob/b34835354b27f1f320ea8438edf2e9d170e69c84/argi-orchestration/argi-agent-framework/src/main/java/io/github/agentic/ai/agent/orchestration/a2a/A2aNodeActionWithConfig.java#L253;https://github.com/agentic-ai-java/argi/blob/b34835354b27f1f320ea8438edf2e9d170e69c84/argi-orchestration/argi-agent-framework/src/main/java/io/github/agentic/ai/agent/orchestration/a2a/A2aNodeActionWithConfig.java#L287。

本地 HTTP/SSE 复现

  1. 在 127.0.0.1 随机端口启动测试 HTTP 服务,消费请求,返回 Content-Type: text/event-stream。
  2. 发送并 flush 一个 SSE 注释行,然后由受控 latch 保持响应打开,不继续发送数据。
  3. 通过 A2aNodeActionWithConfig 的公开 apply 获取 Flux 并订阅,确认客户端工作线程已进入 BufferedReader.readLine。
  4. dispose 订阅,观察线程与资源;最后由测试服务关闭响应,以保证夹具清理。

实测:取消前、取消 250ms 后均有 boundedElastic-1 停留在该 HTTP 读取调用栈;只有测试服务关闭远端响应后读取线程才退出。本次验证是本地可控 HTTP/SSE,不是生产远端服务验收,也没有将“取消外层订阅”误当作传输已释放。

交付与验收

  • 将订阅取消绑定到当前请求中止/响应与输入流关闭,及时解除阻塞读取并释放本次调用持有的连接和 worker。
  • 处理连接建立、响应头到达、响应读取和正常完成各阶段的取消竞态,关闭操作幂等。
  • 区分主动取消与传输失败,不把取消转成成功 done 或普通错误文本;错误终态与 [BUG][OPT-22] A2A 传输与解析失败应传播结构化错误而非正常文本完成 #213 协调。
  • 用静默 SSE、响应头前阻塞、持续输出、远端正常结束与并发多调用验证;不通过全局关闭共享线程池终止单个运行。
  • 必要的等待/读取时限作为补充边界,不能仅依靠远端最终断开或很长的超时释放资源;取消仍须主动生效。

与既有任务的边界

关联 #212、#213。#212 聚焦队列/背压满载与可靠终态,旧问题依据是 LinkedBlockingQueue;本单针对当前 Flux 实现,即使没有队列满载、远端没有任何业务事件也能复现的传输取消缺陷。它不是 Extensions 的持久化 A2A 取消或 SDK 升级任务。

验证基线与变更约束

Core b34835354b27f1f320ea8438edf2e9d170e69c84;Extensions cb2635ab2fc338e8aade28a132a15f48b4e655fc。本轮 Core 19 模块 clean install 通过,2517 个测试中 173 个跳过;此处缺陷由额外的独立 Java 探针确认,说明已有测试尚未覆盖对应行为,不代表真实模型或生产环境验收。

优先修复现有契约的实现,不强制拆模块或重命名,不让独立 Agent/Workflow 反向依赖 Graph/Loop,不向 Core 引入供应商 SDK。若必须改变公开 API、事件、配置或持久格式,说明较小替代方案、消费者影响、收益、迁移成本与目标版本;不得通过删测试、吞错误或弱化取消保证获得通过。

Activity

  1. konlue commented on Oct 28, 2026

    @konlue
    Contributor

    你好,我想认领 #277。

    基于 main 04c5686fe 确认:

    1. 阻塞与取消感知:createStreamingFlux 在 Schedulers.boundedElastic() 上同步执行 HttpClient5 的 httpClient.execute(post, response -> ...)(L253/L264),SSE 用 BufferedReader.readLine() 逐行读(L290)。循环条件 !sink.isCancelled() && (line = reader.readLine()) != null 只能在两次读取之间看到取消——一旦线程已经阻塞在 readLine(),取消不会唤醒它。
    2. 传输侧缺少释放动作:取消路径没有任何传输侧动作:没有 post.cancel(),CloseableHttpClient 的 close() 要等 execute 返回后由 try-with-resources 才执行 → 远端只发一个注释行后保持静默时,worker 和连接一起被钉住,正是 issue 里 boundedElastic-1 停在该调用栈的现象。
    3. 取消与失败未区分:读取被打断后会走 sendSpan.error(e) / receiveSpan.error(ex),把主动取消记成传输失败(错误文本与 done 已被 emit / complete 的 isCancelled 挡住,但 span 归因是错的)。

    方案(单 PR,不动公开 API / 事件 / 配置 / 持久格式)

    • 绑定传输侧释放:sink.onCancel(...) 绑定传输侧释放:post.cancel()(请求已进入执行时中止本次 exchange)+ close() 本次调用自己创建的 CloseableHttpClient(未进入执行/正在等响应头时由连接管理器关闭兜底)。两者都幂等,且该 client 是每次调用新建的,不触碰任何共享线程池或共享 client。
    • 前置检查:execute 前补一次 sink.isCancelled() 检查,避免取消后仍发起请求。
    • 修正 span 归因:取消时不再把 span 标成 error/success(!sink.isCancelled() 守卫),非取消路径的错误文本 + done 终态保持原样(与 [BUG][OPT-22] A2A 传输与解析失败应传播结构化错误而非正常文本完成 #213 的终态契约不冲突,只是取消不再被当成失败)。
    • 不新增整体读超时:SSE 本身就是长连接,固定读超时会误杀正常长流;取消必须是主动边界,这一点按 issue 的要求实现为「取消立刻生效」。
  2. added a commit that references this issue on Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions