Repository navigation
fix(runtime-broker): keep the HTTP exchange cancel off the deadline completion path - #13562
Conversation
…ompletion path When a transport call hits its deadline, the cancel of the underlying HTTP exchange ran synchronously on the shared CompletableFuture delay thread and, because of how nested completions propagate, before the caller's own continuation. The caller therefore paid for the whole HttpClient cancel and socket close, which on Windows occasionally takes about 3 s and made ManagedCsiAcknowledgementHttpTransportTest.totalDeadlineIncludesAResponseBodyThatNeverFinishes exceed its 3 s bound (runs 37543910341, 37542230965, 37558689200). A slow cancel on that thread also delays every other orTimeout in the JVM. Run the cancel on an async continuation instead, at both deadline sites, and add a regression test that injects a blocking cancel and asserts the caller is released first. Deadline semantics, error codes and the connection close are unchanged.
|
✅ Qwen Triage finished — CI landed green on ✅ Qwen Triage 已完成 —— |
|
Thanks for the PR! Template looks good ✓ — every required heading is present, the Tested-on table is filled in honestly (Windows marked Problem: observed, not theoretical. Three real Direction: aligned, and it earns its place beyond the flake. Size: not applicable. Approach: the scope feels right, and it matches what I'd have written independently — two one-token changes plus a comment at each site, no new field, no executor lifecycle to own, and no new test dependency. On that last point: the module is JUnit-only (no Mockito in Two things worth thinking about before merge. Neither blocks:
Risk: no elevated risk signals — neither changed file matches the revert-correlated path list. The honest caveat is evidential rather than structural: the job that matters most here ( Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ —— 所有必需小标题都在,Tested-on 表格如实填写(Windows 标 问题: 已观测到的,不是理论性加固。一天之内在三个互不相关的 PR 上出现真实的 方向: 对齐,而且它的价值不止于修 flaky。 规模: 不适用。 方案: 范围合理,和我独立想到的做法一致——两处各改一个 token,加上注释,没有新字段,没有需要自己维护的 executor 生命周期,也没有引入新的测试依赖。关于最后一点:该模块只用 JUnit( 有两点在合并前值得想一想,都不构成阻塞:
风险: 无升级风险信号——两个改动文件都不匹配与回滚相关的路径列表。需要如实说明的是证据层面的保留,而不是结构层面的:这里最关键的 job( 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewIndependent proposal first (written from the title and "Why it's needed", before reading the diff): the cancel is running on the thread that completes the deadline, so it has to be handed to some other thread — and the cheapest correct way to say that in No critical blockers. I checked the things that could actually break here rather than the things that look unusual:
Non-blocking observations, both already raised in the gate comment and repeated here only so they stay visible next to the code: the cancel now runs on Test evidenceThis is an unattended CI run, so nothing here was built or executed by the review — PR-derived code is never run. The evidence below is this PR's own CI, read through the API for the reviewed commit. No check on The important caveat: the fix is not yet confirmed where it matters. Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Sandboxed verification would settle what CI cannot yet: 中文说明代码审查 先写独立方案(只看标题和"为什么需要",没看 diff):cancel 跑在完成 deadline 的那个线程上,所以必须交给别的线程——而在 没有阻塞性问题。 我核的是这里真会出问题的点,而不是看起来不寻常的点:
非阻塞观察(gate 评论里已经提过,这里重复只是为了让它们贴着代码可见):cancel 现在跑在 测试证据 这是无人值守的 CI 运行,所以审查过程中没有构建或执行任何代码——绝不运行 PR 派生的代码。下面的证据来自本 PR 自己的 CI,通过 API 读取被审查 commit 的结果。 关键的保留意见:修复还没有在它真正该被验证的地方得到确认。 沙箱验证可以补上 CI 目前补不了的那一环: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 4/5 — the mechanism is verified against the code, the regression test can only pass for the right reason, and the change is as small as this fix can be; the two reservations are the common-pool destination and the fact that Windows CI hasn't spoken yet. Stepping back: this is a good PR and it is the kind I'd want to maintain. The diagnosis is not hand-waved — it names a specific My independent proposal and the diff converged, so there is no simpler path I'm withholding. If I had to maintain this in six months I'd thank the author, with one mild curse: On the evidence question I care most about: I verified the problem exists (three real failing runs, Windows-only, plus a minimal repro), and I verified the test is load-bearing by reasoning through both the reverted and fixed paths rather than trusting the reported numbers. What I could not verify is the thing the PR is ultimately for — that Two PR CI workflow runs are still in flight on this commit, so approval is deferred until CI lands green on 中文说明Confidence: 4/5 —— 机制已经对照代码核实,回归测试只可能因为正确的原因而通过,改动也已经是这个修复能有的最小形态;两点保留是 common pool 这个落点,以及 Windows CI 还没有出结果。 退一步看:这是个好 PR,也是我愿意接手维护的那种。诊断不是含糊带过的——它点明了 我的独立方案和 diff 收敛到了同一个做法,所以我没有藏着一个更简的路径。如果六个月后要我维护这段代码,我会感谢作者,只会有一句轻微的抱怨: 关于我最在意的证据问题:我核实了问题确实存在(三个真实的失败 run、只在 Windows 出现、外加一个最小复现),也通过推演回退与修复两条路径核实了测试是承重的,而不是照搬作者给出的数字。我没能核实的是这个 PR 最终要达成的那件事—— 这个 commit 上还有两个 PR CI workflow run 在跑,因此 approve 推迟到 CI 在 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
wenshao
left a comment
There was a problem hiding this comment.
评审结论:未发现本次变更引入的阻塞性问题(C=0)。评审版本:829c0d29e4bc822225405411027d9aa2b0033cf0。
核对了两处取消回调、公共 post() 的调用链、异常映射和已有评审。whenCompleteAsync 将底层 exchange 的取消移出 deadline 完成线程,调用方接收的仍是原来的结果 stage;取消条件及错误码保持不变。没有待处理的 Critical 评审线程。
独立验证(macOS arm64,JDK 21.0.11,Maven 3.9.11,隔离临时快照):
- 精确 head 上运行
mvn -B -Dtest=ManagedCsiAcknowledgementHttpTransportTest,HttpRuntimeTransportTest test:73 tests,0 failures,0 errors,1 skipped(原有 TypeScript handler 集成测试,临时快照未构建 Node bundle)。新增慢取消测试约 0.618 s,响应体持续阻塞的 deadline 测试约 0.628 s,deadline/调用方取消后的连接关闭测试约 0.619 s。 - 精确 base
4970bfa172077147adf7abc226e3d0462b179ac5的产品代码配合同一 head 测试类:4 tests 中仅新增测试失败,耗时约 5.628 s,报错为the deadline failure waited for the exchange cancel。确认该回归测试能区分修复前后的行为。 - 已直接核对当前提交的 Windows / Java 21 CI 日志:新增测试所在类 4 tests 全部通过,总耗时 0.827 s;Java CI workflow 已成功。此前评论中“Windows 仍在排队”的信息已过时。单次绿色 CI 不能证明所有 Windows 时序问题都已消失,但本次取消阻塞机制已有先红后绿的验证。
对已有 executor 讨论补充一点:JDK 21 的默认 async executor 在 common pool 并行度不足 2 时,会为每个任务创建新线程,因此“2 核机器只有一个 common-pool worker,所有取消都在它上面串行执行”的具体判断不成立;无需仅据此扩大本 PR 的修改范围。见 JDK 21 CompletableFuture 文档。
🔍 Local Maintainer Verification — PR #13562Verdict: ✅ merge-ready — 754 assertions green, 0 failures (732 full-suite + 20 targeted-repeat + 1 mutation kill + 1 static gate; tally in 🇨🇳 中文摘要结论:✅ 可合并 — 共 754 项断言全部通过、0 失败(全套件 732 + 定向重复 20 + 变异击杀 1 + 静态门禁 1)。
EnvironmentmacOS (darwin, Apple Silicon) · OpenJDK 21.0.12 (Homebrew) · Maven 3.9.16 · isolated worktree at the PR head, differing from merge-base only in this PR's two files (verified: What was verified
A/B red–green proofThe PR touches exactly one production file, so the mutation matrix is that one file: reverted in place to the merge-base version (
Code review notes
CI cross-check
Not covered
Methodology
Evidence capturesHead — regression class green (live run): Mutant (base transport) — red with the predicted signature (live run): Full suite on head — 735/0/0/3 + key per-test timings: Local verification performed by @wenshao's maintainer harness; artifacts ( |
qwen-code-review-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
qqqys
left a comment
There was a problem hiding this comment.
APPROVE — reviewed at 829c0d29e4bc822225405411027d9aa2b0033cf0. A two-word production change, so I read both sites in their full method context and hunted specifically for the races that moving a completion callback off-thread tends to introduce. I found none, and the witness is mutation-killed rather than merely green.
What the fix actually removes
result.orTimeout(...) completes the stage on CompletableFuture.Delayer — a single shared daemon scheduler for the whole JVM. The cleanup callback that followed it called exchange.cancel(true), which closes a socket, and result.cancel(false). Under whenComplete that callback ran inline on whatever thread completed the stage, so on a deadline it ran on the Delayer thread and ahead of the caller's continuation. A slow HttpClient cancel — observed at ~3 s on Windows — therefore did two kinds of damage at once: it inflated the caller's wall-clock, and it stalled every other orTimeout in the process behind one blocked timer thread. whenCompleteAsync moves the close onto the common pool, off both paths. That is the correct instrument for this defect, and it is the same class of hazard #13388 addressed for virtual-thread carriers.
Why I am satisfied there is no new race
I checked the four things that could have gone wrong:
- The other two callbacks are correctly left alone.
exchange.whenComplete(...)at line 198 and its twin at 1057 are unchanged, and rightly so: they fire when the exchange completes, on anHttpClientthread, never on the Delayer. Their work isresult.complete(parse(...))— CPU-bound parsing, not a socket close. So the fix covers exactly the sites that had the hazard and does not churn the ones that did not. Both changed sites are symmetric (attest~225,post~1118), so neither half of the transport is left with the old behaviour. resultandreturnedare distinct objects, as the cancels assume.orTimeoutreturnsthis, soresult.orTimeout(...)is stillresult, and.handle(...)produces the new future bound toreturned.result.cancel(false)therefore releases the source rather than the stage the caller holds, andreturned.isCancelled()reads the caller-facing one. The distinction is load-bearing for this callback and it holds.- Late execution is harmless here. The action touches only call-local cleanup state — the captured
exchangeandresult— and nothing downstream reads it. Cancelling a dependent stage does not cancel its source inCompletableFuture, so the explicitresult.cancel(false)is still doing real work; it just does it microseconds later. If the exchange completed normally in the meantime,result.complete/completeExceptionallyon an already-completed or cancelled future is a no-op returning false, and the action's ownerror != nullguard means it does nothing at all on the success path. - Ordering is now weaker, and nothing depends on it. With
whenCompletethe close happened-before the caller observed the failure; withwhenCompleteAsyncit may happen after. For socket cleanup on an abandoned exchange that is the desired direction, and the caller only ever observes the returned stage.
The witness is real, not decorative
I initially doubted that deadlineFailureDoesNotWaitForTheExchangeCancel could distinguish the two variants, since returned completes before its dependents fire and a waiting join() might be released either way. The executed A/B settles it against my reading: reverting the single production file to merge-base (whenCompleteAsync count 2 → 0) turns the test red after 5.839 s with the deadline failure waited for the exchange cancel ==> expected: <true> but was: <false>, matching the before-behaviour the PR claims, while head runs it in ~0.7 s. So on base the caller genuinely does block behind the inline cancel for the full latch window — the ordering is not merely theoretical. The most useful part of that result is its specificity: the mutant is killed only by the new test, the other three in the class staying green on base, so the witness pins the fixed behaviour and nothing beside it.
The StalledExchangeClient is the right shape for this: overriding cancel to block until the caller has returned makes "the caller waited for the cancel" directly observable instead of inferred from timing, and the endpoint at 127.0.0.1:9 is never contacted, so the test measures the completion path rather than any network behaviour.
CI
Every check is green at this head except review-pr, which was still running and is not a gate for this change. That includes the lanes that matter here — windows-latest / Java 21 (the platform where the flake was observed, and the one local verification could not cover), Runtime Broker and Managed Agent MariaDB / Java 21, Hosted process fault gates / MySQL 8.4 / Java 21, Test, and Lint & Static. I re-read the CI state immediately before publishing rather than relying on the snapshot I took mid-review.
One tradeoff, recorded rather than blocking
The close now runs on ForkJoinPool.commonPool(), whose parallelism is availableProcessors - 1, so a burst of simultaneous deadline cancellations could occupy common-pool threads with blocking socket closes. I do not think this warrants a change: HttpClient.sendAsync already uses that pool by default, the pool has multiple threads where the Delayer had one, and the previous behaviour was strictly worse on exactly the axis this PR fixes. A dedicated single-purpose executor would be the alternative if the common pool ever shows contention here, and it is not worth pre-empting that on speculation.
I ran no build, test or PR-derived code; this is a static read of both changed sites and their surrounding methods at the head above, with the execution evidence taken from the recorded local A/B and from CI rather than re-run by me.
…enLM#13593) Append rows for the six managed-agent PRs merged after the ledger's first cutoff (0c13502), in merge order and written to their merged final bodies: the surface registry (QwenLM#13543), the Runtime Broker deadline-cancel fix (QwenLM#13562), the H4a child record contract (QwenLM#13505), Hosted project context (QwenLM#13168), the H6a automation record contract (QwenLM#13536) and the H5a channel record contract (QwenLM#13548). Move the cutoff sentence in both languages to d0ddd02. No other row changes. Co-authored-by: wenshao <[email protected]>



What this PR does
When a Runtime Broker HTTP call hits its deadline, the transport cancels the underlying HTTP exchange so the connection is closed. That cancel used to run synchronously on the thread that completes the deadline, which is the JVM-wide
CompletableFuturedelay thread, and it ran before the caller's own continuation. This PR moves the cancel onto an async continuation so the caller observes the deadline failure immediately, and the delay thread never blocks on a socket close. It also adds a deterministic regression test that injects a slow cancel and asserts the caller is released first. The deadline semantics, the error code and the connection close itself are unchanged.Why it's needed
ManagedCsiAcknowledgementHttpTransportTest.totalDeadlineIncludesAResponseBodyThatNeverFinishesis flaky on thewindows-latest / Java 21job: it asserts that a call with a 500 ms deadline fails within 3 s, and on Windows it intermittently takes 3.4–4.0 s. It failed on three unrelated PRs within a day (runs 37543910341, 37542230965 and 37558689200), never on Linux or macOS.The wall-clock the test measures was not just the deadline. Because of how
CompletableFuturepropagates nested completions, the synchronous cancel callback ran on the timer thread before the caller's stage was completed, so the caller's latency included the whole JDKHttpClientcancel and socket close. On Linux that costs 0–2 ms; on Windows it occasionally takes about 3 s. A minimal reproduction with a 1 s sleep inside the cancel callback made the caller'sjoin()return after 1.5 s instead of 0.5 s. Besides the flaky test, a slow cancel on the shared delay thread also postpones every otherorTimeoutin the process.Reviewer Test Plan
How to verify
runtime-brokermodule tests and confirmManagedCsiAcknowledgementHttpTransportTestpasses, including the newdeadlineFailureDoesNotWaitForTheExchangeCancel, and that the formerly flakytotalDeadlineIncludesAResponseBodyThatNeverFinishesnow finishes in roughly the 500 ms deadline plus JVM warm-up rather than seconds.whenCompleteAsyncchanges in the transport and run the class again: the new test fails withthe deadline failure waited for the exchange cancelafter about 5.7 s, because the injected cancel blocks the caller until its 5 s guard expires.HttpRuntimeTransportTest.closesTheConnectionOnTheDeadlineAndOnCallerCancelstill passes.windows-latest / Java 21job of this PR: the test class should pass well under the 3 s bound.Evidence (Before & After)
Before (unfixed transport, new test):
Tests run: 4, Failures: 1 ... deadlineFailureDoesNotWaitForTheExchangeCancel -- Time elapsed: 5.675 s <<< FAILURE! ... the deadline failure waited for the exchange cancel ==> expected: <true> but was: <false>After:
runtime-brokerfull suiteTests run: 735, Failures: 0, Errors: 0, Skipped: 5,checkstyle:checkclean. The test class repeated 5× on Linux (JDK 25) with the new test at 0.66–0.70 s and the formerly flaky test at 0.62–0.65 s, and 3× on macOS (JDK 21.0.12) with 0.23 s / 0.52 s once the JVM is warm.Tested on
Environment (optional)
Unit tests only. Linux: Maven 3.9.9 on JDK 25 (module compiles with
--release 21). macOS 10.15 x86_64: JDK 21.0.12, targeted test class only. Windows is covered by CI.Risk & Scope
HttpClientcancel occasionally takes about 3 s on Windows (it matches the client's 3 s idle selector wake-up, but that was not confirmed on a Windows box). This PR removes it from the caller's path regardless of its cause.Linked Issues
Failing runs: https://github.com/QwenLM/qwen-code/actions/runs/37543910341/job/112577931887, https://github.com/QwenLM/qwen-code/actions/runs/37542230965, https://github.com/QwenLM/qwen-code/actions/runs/37558689200
中文说明
本 PR 做了什么
Runtime Broker 的 HTTP 调用到达 deadline 时,transport 会取消底层 HTTP exchange 以关闭连接。此前这个取消是在完成 deadline 的线程上同步执行的,而那个线程是 JVM 全局共享的
CompletableFuture延时线程,并且它跑在调用方自己的后续回调之前。本 PR 把取消挪到异步 continuation 上:调用方立即观察到 deadline 失败,延时线程也不再因关闭 socket 而阻塞。同时新增一个确定性的回归测试:注入一个会阻塞的 cancel,断言调用方先被释放。deadline 语义、错误码和连接关闭本身都没有变化。为什么需要
ManagedCsiAcknowledgementHttpTransportTest.totalDeadlineIncludesAResponseBodyThatNeverFinishes在windows-latest / Java 21job 上不稳定:它断言 500 ms deadline 的调用必须在 3 s 内失败,而在 Windows 上偶尔会耗时 3.4–4.0 s。一天之内它在三个互不相关的 PR 上失败(run 37543910341、37542230965、37558689200),Linux 和 macOS 上从未出现。测试量到的墙钟时间并不只是 deadline。由于
CompletableFuture传播嵌套完成的顺序,同步的 cancel 回调会在定时器线程上、调用方 stage 完成之前执行,因此调用方的延迟包含了整个 JDKHttpClient取消和 socket 关闭的开销。Linux 上这段开销是 0–2 ms,Windows 上偶尔约 3 s。一个最小复现(在 cancel 回调里 sleep 1 s)让调用方的join()在 1.5 s 而不是 0.5 s 才返回。除了这个 flaky 测试之外,共享延时线程上的慢 cancel 还会推迟进程内所有其他orTimeout。评审验证计划
如何验证
runtime-broker模块测试,确认ManagedCsiAcknowledgementHttpTransportTest通过,包括新增的deadlineFailureDoesNotWaitForTheExchangeCancel;原先 flaky 的totalDeadlineIncludesAResponseBodyThatNeverFinishes现在耗时约等于 500 ms deadline 加 JVM 预热,而不是数秒。whenCompleteAsync改回去再跑该测试类:新测试会在约 5.7 s 后以the deadline failure waited for the exchange cancel失败,因为注入的 cancel 会阻塞调用方直到 5 s 保护超时。HttpRuntimeTransportTest.closesTheConnectionOnTheDeadlineAndOnCallerCancel仍然通过。windows-latest / Java 21job:该测试类应远低于 3 s 上限通过。证据(修复前后)
修复前(未修复的 transport + 新测试):
Tests run: 4, Failures: 1 ... deadlineFailureDoesNotWaitForTheExchangeCancel -- Time elapsed: 5.675 s <<< FAILURE! ... the deadline failure waited for the exchange cancel ==> expected: <true> but was: <false>修复后:
runtime-broker全量套件Tests run: 735, Failures: 0, Errors: 0, Skipped: 5,checkstyle:check干净。该测试类在 Linux(JDK 25)上重复 5 次,新测试 0.66–0.70 s,原 flaky 测试 0.62–0.65 s;在 macOS(JDK 21.0.12)上重复 3 次,JVM 预热后分别为 0.23 s / 0.52 s。测试平台
环境(可选)
仅单元测试。Linux:Maven 3.9.9 + JDK 25(模块以
--release 21编译)。macOS 10.15 x86_64:JDK 21.0.12,仅跑了目标测试类。Windows 由 CI 覆盖。风险与范围
HttpClient的取消在 Windows 上偶尔耗时约 3 s 的确切原因(与该客户端 3 s 的空闲 selector 唤醒周期吻合,但没有在 Windows 机器上确认)。无论原因如何,本 PR 都把它移出了调用方路径。关联 Issue
失败的 run:https://github.com/QwenLM/qwen-code/actions/runs/37543910341/job/112577931887、https://github.com/QwenLM/qwen-code/actions/runs/37542230965、https://github.com/QwenLM/qwen-code/actions/runs/37558689200