Skip to content

fix(sdk-java): let a terminal continuation start the next prompt - #7615

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
wenshao:fix/sdk-java-stream-cleanup-capacity
Jul 23, 2026
Merged

fix(sdk-java): let a terminal continuation start the next prompt#7615
wenshao merged 1 commit into
QwenLM:mainfrom
wenshao:fix/sdk-java-stream-cleanup-capacity

Conversation

@wenshao

@wenshao wenshao commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Problem

DaemonSessionClientTest.terminalContinuationCanStartNextPromptAtClientCapacity fails intermittently on CI (example):

java.util.concurrent.ExecutionException: DaemonClientCapacityException: DaemonClient capacity is exhausted
Caused by: java.util.concurrent.RejectedExecutionException: Stream cleanup capacity is exhausted
    at DaemonClient.submit(DaemonClient.java:335)
    at DaemonSessionClient.startPrompt(DaemonSessionClient.java:93)

The flake exposes a real API defect rather than a bad assertion: chaining prompts off completionFuture() while the client is at its prompt-concurrency limit can randomly fail.

Root cause

DaemonClient.submit reserves two semaphores per prompt, but they are released at different times:

  • promptSlots is released synchronously in FutureTask.done(), right before afterCapacityRelease opens the terminal publication gate.
  • streamLifecycleSlots is only released once the SSE stream has finished closing, because registerStreamCleanup attaches a whenComplete callback to the cleanup future, and the terminal path in observe() closes the stream through closeStreamAsync without waiting for it.

So a prompt's stream-cleanup reservation outlives its prompt slot by one generation. Both semaphores were sized to maximumConcurrentPrompts, which leaves no room for that overlap: when the previous prompt's close has not finished by the time its terminal is published, the continuation's startPrompt finds a free prompt slot but no free cleanup slot and throws. Normally the close wins the race; on a loaded runner it does not.

Fix

Size the stream-cleanup semaphore to allow one draining cleanup per prompt slot — exactly the overlap the release ordering can produce. The stream-close executor queue is derived from the same capacity, so it still absorbs every reservation without rejecting work.

Admission backpressure is unchanged in kind: cleanups that stay stalled beyond that headroom still fail fast, now after two generations instead of one. stalledStreamCleanupAppliesBackpressureBeforePromptExecution is updated to stall two cleanups before asserting rejection, and pendingStreamCleanupDoesNotBlockNextPromptAdmission is added to pin the other half of the contract: one pending cleanup must not block the next admission.

With the fix, terminalContinuationCanStartNextPromptAtClientCapacity no longer depends on timing at all — at maximumConcurrentPrompts(1) the second prompt always finds the spare slot.

Verification

  • mvn clean test — 108 tests, green, repeated 3×.
  • mvn checkstyle:check — 0 violations. mvn -DskipTests package — green.
  • Causal check: injecting a 50 ms delay into the closeStreamAsync close task reproduces the exact CI failure on main (same exception chain, same line), and all three tests still pass with that delay once this fix is applied. Reverting only the capacity change with the delay still injected fails all three, so the new tests are not vacuous.
中文说明

问题

DaemonSessionClientTest.terminalContinuationCanStartNextPromptAtClientCapacity 在 CI 上间歇性失败(示例):

java.util.concurrent.ExecutionException: DaemonClientCapacityException: DaemonClient capacity is exhausted
Caused by: java.util.concurrent.RejectedExecutionException: Stream cleanup capacity is exhausted
    at DaemonClient.submit(DaemonClient.java:335)
    at DaemonSessionClient.startPrompt(DaemonSessionClient.java:93)

这个 flake 暴露的是一个真实的 API 缺陷,而不是断言写得太严:客户端跑满 prompt 并发上限时,用 completionFuture() 串接下一个 prompt 会随机失败。

根因

DaemonClient.submit 为每个 prompt 预留两个信号量,但两者释放时机不同:

  • promptSlotsFutureTask.done() 中同步释放,紧接着 afterCapacityRelease 就打开 terminal 发布 gate。
  • streamLifecycleSlots 要等 SSE 流真正关完才释放:registerStreamCleanup 只是在 cleanup future 上挂了一个 whenComplete 回调,而 observe() 的 terminal 路径通过 closeStreamAsync 异步关流,并不等待。

也就是说,一个 prompt 的 stream cleanup 预留会比它的 prompt 槽位多活一代。而两个信号量的容量都按 maximumConcurrentPrompts 配置,没有给这次重叠留出空间:当上一个 prompt 的关流在其 terminal 发布时还没结束,续接里的 startPrompt 能拿到空闲的 prompt 槽位,却拿不到 cleanup 槽位,于是抛异常。平时关流总能赢下这场赛跑,机器负载高时就未必。

修复

把 stream cleanup 信号量的容量放宽为每个 prompt 槽位允许一个正在排水的 cleanup —— 这正是释放顺序会产生的重叠量。stream-close 执行器的队列由同一个容量推导,因此仍能容纳所有预留而不会拒绝任务。

准入背压的性质没有变:滞留超出这个余量的 cleanup 依然快速失败,只是从滞留一代变成滞留两代。stalledStreamCleanupAppliesBackpressureBeforePromptExecution 相应改为挂住两个 cleanup 再断言拒绝;新增 pendingStreamCleanupDoesNotBlockNextPromptAdmission 钉住契约的另一半:单个未完成的 cleanup 不得阻塞下一次准入。

修复后 terminalContinuationCanStartNextPromptAtClientCapacity 完全不再依赖时序 —— 在 maximumConcurrentPrompts(1) 下,第二个 prompt 必然能拿到那个备用槽位。

验证

  • mvn clean test —— 108 个测试全绿,重复跑 3 次。
  • mvn checkstyle:check —— 0 violations;mvn -DskipTests package —— 通过。
  • 因果验证:在 closeStreamAsync 的关流任务中注入 50 ms 延迟,可在 main 上稳定复现 CI 的失败(异常链和行号完全一致);打上本修复后,带着这个延迟三个测试依然全过。若只回退容量改动、保留注入延迟,三个测试全挂,说明新增测试不是空断言。

A prompt reserves both a prompt slot and a stream-cleanup slot in
DaemonClient.submit, but releases them at different times: the prompt slot is
released synchronously in FutureTask.done(), immediately before the terminal
publication gate opens, while the stream-cleanup slot is only released once the
SSE stream has finished closing on the stream-close executor. The terminal path
in observe() closes that stream asynchronously and does not wait for it.

Both semaphores were sized to maximumConcurrentPrompts, so a caller chaining
prompts off completionFuture() at full capacity races the previous prompt's
close: when the close had not finished by the time the terminal was published,
startPrompt failed with DaemonClientCapacityException("Stream cleanup capacity
is exhausted"). This is how the documented chaining pattern behaves under load,
and it made terminalContinuationCanStartNextPromptAtClientCapacity flaky in CI.

Size the stream-cleanup semaphore to allow one draining cleanup per prompt
slot, which is exactly the overlap the release ordering can produce. Admission
backpressure is unchanged in kind: cleanups that stay stalled beyond that
headroom still fail fast, now after two generations instead of one.

The stream-close executor queue is derived from the same capacity, so it still
absorbs every reservation without rejecting work.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template: the headings differ from the repo template (Problem / Root cause / Fix / Verification instead of What this PR does / Why it's needed / Reviewer Test Plan), but the content is thorough and covers everything the template asks for — not worth a round-trip.

Problem: observed CI flake with a linked failure showing the exact exception chain (DaemonClientCapacityExceptionRejectedExecutionException: Stream cleanup capacity is exhausted). The root cause is well-analyzed: promptSlots releases synchronously in FutureTask.done(), but streamLifecycleSlots only releases once the async stream close completes — so a terminal continuation can grab the prompt slot while the previous cleanup still holds the lifecycle slot. Real bug, real reproduction.

Direction: straightforward concurrency fix in the Java daemon SDK. Clearly in scope, no direction concerns.

Size: not applicable — changes are in packages/sdk-java/, not core paths. 40 additions / 4 deletions across 3 files (1 production file, 1 test file, 1 design doc).

Approach: the scope feels right. The fix is one production line — sizing streamLifecycleSlots to maximumConcurrentPrompts * 2 to tolerate one draining cleanup per prompt slot — which is exactly the overlap the release ordering can produce. The streamCloseExecutor queue derives from the same capacity variable, so it stays consistent. Tests are updated to match the new capacity (stall two cleanups before asserting rejection) and a new test pins the contract that one pending cleanup must not block the next admission. Design doc updated in the same commit. No scope creep.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板:标题结构与仓库模板不同(用了 Problem / Root cause / Fix / Verification 而非 What this PR does / Why it's needed / Reviewer Test Plan),但内容完整覆盖了模板要求的所有信息——不值得为此打回。

问题:已观测到的 CI 间歇性失败,附有失败链接,异常链完整(DaemonClientCapacityExceptionRejectedExecutionException: Stream cleanup capacity is exhausted)。根因分析清晰:promptSlotsFutureTask.done() 中同步释放,但 streamLifecycleSlots 要等异步关流完成才释放——因此 terminal continuation 能在上一个 cleanup 仍持有 lifecycle 槽位时拿到 prompt 槽位。真实 bug,有复现。

方向:Java daemon SDK 的并发修复,完全在范围内,无方向性顾虑。

规模:不适用——改动在 packages/sdk-java/,不涉及核心路径。3 个文件共 40 行新增 / 4 行删除(1 个生产文件、1 个测试文件、1 个设计文档)。

方案:范围合理。修复只有一行生产代码——将 streamLifecycleSlots 容量设为 maximumConcurrentPrompts * 2,以容忍每个 prompt 槽位一个正在排水的 cleanup——这正是释放顺序会产生的重叠量。streamCloseExecutor 队列由同一容量变量推导,保持一致。测试相应更新(挂住两个 cleanup 再断言拒绝),新增测试钉住"单个未完成 cleanup 不得阻塞下一次准入"的契约。设计文档在同一提交中更新。无范围蔓延。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 96347c3abf23ac1fd21062bcaa5dba931c21959d · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

Independent proposal: given the root cause (prompt slot releases synchronously in FutureTask.done(), stream lifecycle slot releases asynchronously when the close future completes), the minimal fix is to size streamLifecycleSlots to tolerate one draining cleanup per prompt slot — i.e. maximumConcurrentPrompts * 2. The streamCloseExecutor queue already derives from the same capacity variable, so it stays consistent automatically. Tests need updating: the backpressure test must stall two cleanups to exhaust the new capacity, and a new test should pin that one pending cleanup does not block the next admission.

Comparison with the diff: the PR does exactly this. No simpler path missed.

Production change is one line in DaemonClient.javastreamLifecycleCapacity goes from maximumConcurrentPrompts to Math.min(Integer.MAX_VALUE, maximumConcurrentPrompts * 2L) with a comment explaining the release-ordering overlap. The overflow guard is appropriate (the builder accepts any positive int). The streamCloseExecutor queue picks up the new capacity since it's constructed after the assignment.

Test changes are correct:

  • pendingStreamCleanupDoesNotBlockNextPromptAdmission — submits a prompt whose cleanup stays pending, then verifies a second prompt is admitted. On main (capacity 1) this would fail; with the fix (capacity 2) the second prompt finds the spare slot. Pins the exact contract the CI flake violated.
  • stalledStreamCleanupAppliesBackpressureBeforePromptExecution — now stalls two cleanups before asserting rejection, matching the doubled capacity. Recovery path (complete both cleanups, then admit) is also verified.

Design doc updated in the same commit to describe the new capacity model. No scope creep, no unrelated changes.

No critical blockers, no convention violations.

Testing

No Java/Maven/Docker on this runner — cannot run mvn test locally. CI results serve as evidence:

ubuntu-latest / Java 11:
  DaemonSessionClientTest: 88 tests, 0 failures, 0 errors
  Total: 108 tests, 0 failures, 0 errors, 5 skipped
  BUILD SUCCESS

ubuntu-latest / Java 17:  pass (33s)
ubuntu-latest / Java 21:  pass (37s)
windows-latest / Java 21: pass (57s)
macos-latest / Java 21:   pass (37s)

The author also reports a causal check: injecting a 50 ms delay into closeStreamAsync reproduces the exact CI failure on main, and all tests pass with that delay once the fix is applied. Reverting only the capacity change with the delay still injected fails all three tests — so the new tests are not vacuous.

中文说明

代码审查

独立方案: 根据根因(prompt 槽位在 FutureTask.done() 中同步释放,stream lifecycle 槽位在关流 future 完成时异步释放),最小修复是将 streamLifecycleSlots 容量设为容忍每个 prompt 槽位一个排水中的 cleanup——即 maximumConcurrentPrompts * 2streamCloseExecutor 队列已由同一容量变量推导,自动保持一致。测试需更新:背压测试需挂住两个 cleanup 才能耗尽新容量,并新增测试钉住"单个未完成 cleanup 不得阻塞下一次准入"。

与 diff 对比: PR 完全按此方案实现,没有遗漏更简路径。

生产代码改动仅 DaemonClient.java 一行——streamLifecycleCapacitymaximumConcurrentPrompts 改为 Math.min(Integer.MAX_VALUE, maximumConcurrentPrompts * 2L),附注释解释释放顺序的重叠。溢出保护合理(builder 接受任意正整数)。streamCloseExecutor 队列在赋值之后构造,自动使用新容量。

测试改动正确:

  • pendingStreamCleanupDoesNotBlockNextPromptAdmission——提交一个 cleanup 挂起的 prompt,然后验证第二个 prompt 能被准入。在 main(容量 1)上会失败;修复后(容量 2)第二个 prompt 能拿到备用槽位。精确钉住了 CI 间歇性失败所违反的契约。
  • stalledStreamCleanupAppliesBackpressureBeforePromptExecution——现在挂住两个 cleanup 再断言拒绝,匹配加倍后的容量。恢复路径(完成两个 cleanup 后再准入)也有验证。

设计文档在同一提交中更新,描述新的容量模型。无范围蔓延,无无关改动。

无关键阻塞项,无规范违反。

测试

本 runner 无 Java/Maven/Docker——无法本地运行 mvn test。以 CI 结果为证据:

ubuntu-latest / Java 11:
  DaemonSessionClientTest: 88 个测试,0 失败,0 错误
  合计: 108 个测试,0 失败,0 错误,5 跳过
  BUILD SUCCESS

ubuntu-latest / Java 17:  通过 (33s)
ubuntu-latest / Java 21:  通过 (37s)
windows-latest / Java 21: 通过 (57s)
macos-latest / Java 21:   通过 (37s)

作者还报告了因果验证:在 closeStreamAsync 中注入 50 ms 延迟可在 main 上复现 CI 的失败;打上修复后带延迟全部通过;只回退容量改动保留延迟则三个测试全挂——说明新测试不是空断言。

Qwen Code · qwen3.8-max-preview

Reviewed at 96347c3abf23ac1fd21062bcaa5dba931c21959d · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean across every stage; would merge without hesitation.

This is a textbook concurrency fix. The root cause is real and well-evidenced (linked CI failure, exact exception chain, clear analysis of the semaphore release-ordering mismatch). The fix is the minimal correct change — one production line sizing streamLifecycleSlots to tolerate the one-generation overlap that the release ordering produces. Tests pin both halves of the contract (pending cleanup doesn't block admission; stalled cleanups still apply backpressure), and the design doc is updated in the same commit. No scope creep, no over-engineering, nothing to maintain that isn't explained.

The independent proposal I wrote before reading the diff arrived at the same approach — I didn't find a simpler path the PR missed. CI is green on Java 11/17/21 across ubuntu, windows, and macos. The author's causal verification (inject delay → reproduces on main, passes with fix, reverting capacity change fails again) is exactly the kind of evidence that makes a concurrency fix trustworthy.

中文说明

置信度: 5/5 —— 每个阶段都干净,毫不犹豫可以合并。

这是一个教科书式的并发修复。根因真实且有证据(CI 失败链接、完整异常链、对信号量释放顺序不匹配的清晰分析)。修复是最小正确改动——一行生产代码将 streamLifecycleSlots 容量设为容忍释放顺序产生的一代重叠。测试钉住了契约的两面(未完成的 cleanup 不阻塞准入;滞留的 cleanup 仍施加背压),设计文档在同一提交中更新。无范围蔓延,无过度工程,没有未解释的维护负担。

我在读 diff 之前写的独立方案得出了相同的方法——没有找到 PR 遗漏的更简路径。CI 在 Java 11/17/21、ubuntu/windows/macos 上全绿。作者的因果验证(注入延迟 → 在 main 上复现,修复后通过,回退容量改动又失败)正是让并发修复可信的那种证据。

Qwen Code · qwen3.8-max-preview

Reviewed at 96347c3abf23ac1fd21062bcaa5dba931c21959d · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

— qwen3.7-max via Qwen Code /review

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM. Correct fix — doubling streamLifecycleCapacity to 2x prompt slots accounts for one draining cleanup per prompt slot. The Math.min guard prevents overflow. New test validates the fix, existing test updated for doubled capacity.

— qwen3.8-max-preview via Qwen Code /review

@wenshao
wenshao added this pull request to the merge queue Jul 23, 2026
Merged via the queue into QwenLM:main with commit 17132f7 Jul 23, 2026
76 of 77 checks passed
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.

3 participants