Skip to content

fix(runtime-broker): keep the HTTP exchange cancel off the deadline completion path - #13562

Merged
wenshao merged 1 commit into
mainfrom
fix/runtime-broker-deadline-cancel-off-path
Oct 7, 2026
Merged

wenshao merged 1 commit into
mainfrom
fix/runtime-broker-deadline-cancel-off-path

Conversation

@wenshao

@wenshao wenshao commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

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 CompletableFuture delay 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.totalDeadlineIncludesAResponseBodyThatNeverFinishes is flaky on the windows-latest / Java 21 job: 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 CompletableFuture propagates 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 JDK HttpClient cancel 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's join() 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 other orTimeout in the process.

Reviewer Test Plan

How to verify

  • Run the runtime-broker module tests and confirm ManagedCsiAcknowledgementHttpTransportTest passes, including the new deadlineFailureDoesNotWaitForTheExchangeCancel, and that the formerly flaky totalDeadlineIncludesAResponseBodyThatNeverFinishes now finishes in roughly the 500 ms deadline plus JVM warm-up rather than seconds.
  • To see the regression test catch the bug, revert the two whenCompleteAsync changes in the transport and run the class again: the new test fails with the deadline failure waited for the exchange cancel after about 5.7 s, because the injected cancel blocks the caller until its 5 s guard expires.
  • Confirm the connection is still closed on deadline and on caller cancel: HttpRuntimeTransportTest.closesTheConnectionOnTheDeadlineAndOnCallerCancel still passes.
  • Watch the windows-latest / Java 21 job 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-broker full suite Tests run: 735, Failures: 0, Errors: 0, Skipped: 5, checkstyle:check clean. 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

OS Status
🍏 macOS ✅
🪟 Windows ⚠️
🐧 Linux ✅

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

  • Main risk or tradeoff: the exchange cancel now runs a few microseconds later on the common pool instead of inline. Nothing waits on it, and the existing connection-close tests still pass.
  • Not validated / out of scope: the exact reason the JDK HttpClient cancel 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.
  • Breaking changes / migration notes: none.

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 21 job 上不稳定:它断言 500 ms deadline 的调用必须在 3 s 内失败,而在 Windows 上偶尔会耗时 3.4–4.0 s。一天之内它在三个互不相关的 PR 上失败(run 37543910341、37542230965、37558689200),Linux 和 macOS 上从未出现。

测试量到的墙钟时间并不只是 deadline。由于 CompletableFuture 传播嵌套完成的顺序,同步的 cancel 回调会在定时器线程上、调用方 stage 完成之前执行,因此调用方的延迟包含了整个 JDK HttpClient 取消和 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 预热,而不是数秒。
  • 要看到回归测试能抓住这个 bug,可以把 transport 里两处 whenCompleteAsync 改回去再跑该测试类:新测试会在约 5.7 s 后以 the deadline failure waited for the exchange cancel 失败,因为注入的 cancel 会阻塞调用方直到 5 s 保护超时。
  • 确认 deadline 和调用方取消时连接仍会被关闭:HttpRuntimeTransportTest.closesTheConnectionOnTheDeadlineAndOnCallerCancel 仍然通过。
  • 关注本 PR 的 windows-latest / Java 21 job:该测试类应远低于 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。

测试平台

OS Status
🍏 macOS ✅
🪟 Windows ⚠️
🐧 Linux ✅

环境(可选)

仅单元测试。Linux:Maven 3.9.9 + JDK 25(模块以 --release 21 编译)。macOS 10.15 x86_64:JDK 21.0.12,仅跑了目标测试类。Windows 由 CI 覆盖。

风险与范围

  • 主要风险或取舍:exchange 的取消现在晚几微秒在 common pool 上执行而不是内联执行。没有任何逻辑等待它,现有的连接关闭测试仍然通过。
  • 未验证 / 范围之外:JDK 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

…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-code-review-bot

qwen-code-review-bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Qwen Triage finished — CI landed green on 829c0d2 and the deferred approval was posted. finalize run

✅ Qwen Triage 已完成 —— 829c0d2 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — every required heading is present, the Tested-on table is filled in honestly (Windows marked ⚠️ rather than claimed), and the Chinese section mirrors the English one paragraph for paragraph.

Problem: observed, not theoretical. Three real windows-latest / Java 21 failures in one day on unrelated PRs (runs 37543910341, 37542230965, 37558689200), never on Linux or macOS, plus a minimal repro — a 1 s sleep inside the cancel callback pushes the caller's join() from 0.5 s to 1.5 s. That is a proper before/after, and the ordering claim survives checking against the code rather than just against the description: acknowledgeCsi returns post(...).thenApply(...), so the caller's stage is a dependent of returned registered after the cleanup whenComplete inside post(). On the exceptional path CompletableFuture drains the source's remaining dependents before the caller's own stage is completed, so the cancel genuinely sits inside the caller's measured latency. The 500 ms deadline was never the whole story.

Direction: aligned, and it earns its place beyond the flake. orTimeout runs its timeout task on a single JVM-wide delay thread, and this module leans on that thread hard — RuntimeBrokerService alone has seven more orTimeout deadlines sharing it. A ~3 s socket close parked there postpones every one of them, so this is a latency-isolation fix with a flaky test as the visible symptom. Nothing here touches a public contract, auth, sandbox, model selection, telemetry or release surface; deadline semantics, the error code and the connection close are all unchanged. CHANGELOG: no direct reference, and none expected — transport-internal threading.

Size: not applicable. packages/sdk-java/** is not a core path and this is a single package. 10 production lines (6 of them comments) + 115 test lines.

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 runtime-broker/pom.xml), so the ~60-line HttpClient double is the honest option rather than a reuse miss — HttpClient is an abstract class with twelve abstract methods and there is nothing lighter already installed. Both sites in HttpRuntimeTransport are covered, which is the pair the flaky test actually drives.

Two things worth thinking about before merge. Neither blocks:

  • whenCompleteAsync with no executor lands on ForkJoinPool.commonPool(), whose parallelism is availableProcessors - 1 — one thread on a 2-core runner. The PR's own argument is "don't let a slow cancel hog a shared thread", and a 3 s Windows cancel would hog the entire common pool for those 3 s, serialising concurrent deadline cancels and anything else the process runs there. The HttpClient already owns an executor reachable through client.executor(), so the cancel could run on the same pool that drove the exchange — per-client blast radius instead of JVM-wide. Worth noting the new stub returns Optional.empty() there, so that variant needs an orElse fallback to keep the test meaningful. This is strictly better than today either way; the delay thread is one thread for the whole JVM.
  • KubernetesHttpRuntimeClient.exchangeBytes (around lines 165–170) carries the identical shape: .orTimeout(...) followed by a synchronous result.whenComplete(... exchange.cancel(true)), with the caller receiving result.exceptionallyCompose(...) — again a dependent registered after the cleanup. It isn't exercised by the flaky test, so leaving it out keeps this PR focused, but it is the same latent instance and a reasonable follow-up.

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 (windows-latest / Java 21) is still queued, so the flake itself is not yet confirmed fixed by CI on this commit.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 所有必需小标题都在,Tested-on 表格如实填写(Windows 标 ⚠️ 而不是声称已测),中文部分与英文逐段对应。

问题: 已观测到的,不是理论性加固。一天之内在三个互不相关的 PR 上出现真实的 windows-latest / Java 21 失败(run 37543910341、37542230965、37558689200),Linux 和 macOS 从未出现;并且有最小复现——在 cancel 回调里 sleep 1 s,调用方的 join() 就从 0.5 s 变成 1.5 s。这是合格的 before/after。而且这个顺序判断对照代码也成立,不只是对照描述成立:acknowledgeCsi 返回的是 post(...).thenApply(...),所以调用方的 stage 是 returned 的一个依赖,注册顺序在 post() 内部的清理 whenComplete 之后。在异常传播路径上,CompletableFuture 会先把源 future 上剩余的依赖跑完,再完成调用方自己的 stage,因此 cancel 确实落在调用方量到的墙钟时间里。那 500 ms deadline 从来不是全部开销。

方向: 对齐,而且它的价值不止于修 flaky。orTimeout 的超时任务跑在 JVM 全局唯一的延时线程上,而这个模块非常依赖它——光是 RuntimeBrokerService 就还有七处 orTimeout 共用同一个线程。一次约 3 s 的 socket 关闭停在那里,会把这些 deadline 全部推迟。所以这是一个延迟隔离修复,flaky 测试只是它暴露出来的症状。这里没有触及任何公开契约、auth、sandbox、模型选择、telemetry 或发布面;deadline 语义、错误码和连接关闭本身都没有变化。CHANGELOG:没有直接对应条目,也不需要有——这是 transport 内部的线程模型。

规模: 不适用。packages/sdk-java/** 不是核心路径,而且只涉及一个 package。生产代码 10 行(其中 6 行是注释)+ 测试 115 行。

方案: 范围合理,和我独立想到的做法一致——两处各改一个 token,加上注释,没有新字段,没有需要自己维护的 executor 生命周期,也没有引入新的测试依赖。关于最后一点:该模块只用 JUnit(runtime-broker/pom.xml 里没有 Mockito),所以那个约 60 行的 HttpClient 测试替身是合理选择,不算"没有复用现成实现"——HttpClient 是有十二个抽象方法的抽象类,而已安装依赖里没有更轻的方案。HttpRuntimeTransport 的两处都覆盖了,正是 flaky 测试实际走到的那一组。

有两点在合并前值得想一想,都不构成阻塞:

  • 不带 executor 的 whenCompleteAsync 会落到 ForkJoinPool.commonPool(),其并行度是 availableProcessors - 1——在 2 核 runner 上就是一个线程。本 PR 自己的论点是"不要让一次慢 cancel 占住共享线程",而 Windows 上一次 3 s 的 cancel 会把整个 common pool 占住 3 s,让并发的 deadline cancel 以及进程里其他用 common pool 的工作排队。HttpClient 本身就持有一个 executor,可以通过 client.executor() 拿到,所以 cancel 可以跑在驱动这次 exchange 的同一个池上——影响范围是单个 client 而不是整个 JVM。需要注意的是新测试替身的 executor() 返回 Optional.empty(),所以那种写法需要 orElse 兜底,才能让测试继续有意义。无论如何这都比现状严格更好:延时线程是整个 JVM 只有一个。
  • KubernetesHttpRuntimeClient.exchangeBytes(约 165–170 行)是完全相同的结构:.orTimeout(...) 之后跟着同步的 result.whenComplete(... exchange.cancel(true)),而调用方拿到的是 result.exceptionallyCompose(...)——同样是注册在清理之后的依赖。它没有被这个 flaky 测试覆盖,所以不放进来能让本 PR 保持聚焦,但这是同一个潜在问题,适合作为后续 PR。

风险: 无升级风险信号——两个改动文件都不匹配与回滚相关的路径列表。需要如实说明的是证据层面的保留,而不是结构层面的:这里最关键的 job(windows-latest / Java 21)仍在排队,所以在这个 commit 上,flake 本身还没有被 CI 确认修复。

进入代码审查 🔍

— Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 829c0d29e4bc822225405411027d9aa2b0033cf0 · re-run with @qwen-code /triage

@qwen-code-review-bot

qwen-code-review-bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Code review

Independent 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 CompletableFuture is the *Async variant of the same stage. I'd have changed whenComplete to whenCompleteAsync at the deadline-cleanup site, kept the guard condition untouched, and added a test that makes the cancel deliberately slow so the ordering is observable rather than inferred. The diff does exactly that, at both sites in HttpRuntimeTransport rather than only the one the flaky test reaches. It matches my baseline; I did not find a simpler path it missed.

No critical blockers. I checked the things that could actually break here rather than the things that look unusual:

  • The connection still gets closed. whenCompleteAsync fires on cancel exactly as whenComplete did — cancelling returned completes it, which drives the dependent — and the error != null || returned.isCancelled() guard is unchanged, so both the deadline path and the caller-cancel path still reach exchange.cancel(true). The existing HttpRuntimeTransportTest.closesTheConnectionOnTheDeadlineAndOnCallerCancel is the guard for this, and it is the right one: it loops over {deadline, caller cancel} and asserts the server observes the close within 2 s. Two seconds is far more slack than the few-microsecond deferral this introduces, so it should not become flaky in the other direction.
  • The new test is load-bearing, not decorative. With the fix reverted, the injected cancel blocks the delay thread before the caller's stage completes, so join() stalls until the 5 s guard expires and cancelSawCallerReturn is false — the reported "Before" (5.675 s, expected: <true> but was: <false>) is what that path produces. With the fix, the cancel waits on a different thread and sees the latch already counted down. The assertion can only pass for the right reason. It also correctly returns Redirect.NEVER from the stub, which matters because acknowledgeCsi throws IllegalArgumentException on any other redirect policy before it ever reaches post().
  • No dead stub surface. All twelve abstract HttpClient methods are implemented, every one of the ten new imports is used (UnusedImports is one of only two import checks this module's checkstyle enables — ImportOrder is commented out), and exchange in post() is the very object sendAsync returned, so the overridden cancel() is the one the production path calls.
  • No documentation drift. Nothing in docs/ describes the deadline-cancel threading, and the BoundedBodySubscriber javadoc still reads correctly — it documents that a stalled body is bounded by the stage deadline, which this change does not alter.

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 ForkJoinPool.commonPool() (parallelism availableProcessors - 1, so one thread on a 2-core runner) while client.executor() would scope it to the client that owns the exchange; and KubernetesHttpRuntimeClient.exchangeBytes keeps the identical synchronous shape. Neither is a regression against today — a single JVM-wide delay thread is the worse of the two — and the duplicated three-line comment at both sites is warranted here, since the why is a JDK completion-ordering subtlety that reads like a no-op change otherwise.

Test evidence

This 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 829c0d29e4bc822225405411027d9aa2b0033cf0 has a failure conclusion, so there is no failing-job log to quote.

The important caveat: the fix is not yet confirmed where it matters. windows-latest / Java 21 — the only job on which this test has ever failed, and the only platform that can demonstrate the flake is gone — is still queued. macos-latest / Java 21 is queued too. The Linux Java jobs are in_progress. Everything completed so far is orchestration (authorize, assign, label, Classify PR, Flyway migration version uniqueness); the Test (macos-latest/windows-latest, Node 22.x) and integration entries are skipped, which is expected for a Java-only diff. So the current green is real but says nothing about the change. The finalize job rewrites the region below in place once CI settles.

Final CI results for 829c0d2 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Flyway migration version uniqueness ✅ success
Hosted process fault gates / MySQL 8.4 / Java 21 ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
macos-latest / Java 21 ✅ success
Real daemon E2E / Java 11 ✅ success
Runtime Broker and Managed Agent MariaDB / Java 21 ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
windows-latest / Java 21 ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Sandboxed verification would settle what CI cannot yet: @qwen-code /verify — that the new deadlineFailureDoesNotWaitForTheExchangeCancel is genuinely load-bearing, i.e. that it fails when the two whenCompleteAsync calls are reverted to whenComplete and passes with them in place. That A/B is the whole claim of this PR, and a green suite on the fixed tree alone does not distinguish it from a test that would pass either way. The author states the reverted run fails at 5.675 s with expected: <true> but was: <false>; that is the author's claim, not independently re-run here. Separately, the flake itself is only observable on windows-latest / Java 21, which is still queued — until it lands green on this commit, "the Windows flake is fixed" rests on the Linux and macOS timings the author reported plus the mechanism, not on a Windows run of this code.

中文说明

代码审查

先写独立方案(只看标题和"为什么需要",没看 diff):cancel 跑在完成 deadline 的那个线程上,所以必须交给别的线程——而在 CompletableFuture 里表达这件事最省的正确写法,就是同一个 stage 的 *Async 变体。我会把 deadline 清理处的 whenComplete 改成 whenCompleteAsync,守卫条件不动,再加一个故意让 cancel 变慢的测试,把这个顺序变成可观测的而不是靠推断。diff 做的正是这件事,而且是在 HttpRuntimeTransport 的两处都改,不只是 flaky 测试走到的那一处。它和我的基线一致,我没有找到它漏掉的更简路径。

没有阻塞性问题。 我核的是这里真会出问题的点,而不是看起来不寻常的点:

  • 连接仍然会被关闭。 whenCompleteAsync 在 cancel 时和 whenComplete 一样会触发——取消 returned 会完成它,从而驱动依赖——并且 error != null || returned.isCancelled() 这个守卫没有改动,所以 deadline 路径和调用方取消路径都仍然会走到 exchange.cancel(true)。现有的 HttpRuntimeTransportTest.closesTheConnectionOnTheDeadlineAndOnCallerCancel 正是这一点的守卫,而且是对的那个:它对 {deadline, caller cancel} 两种情况循环,断言服务端在 2 s 内观察到关闭。2 s 的余量远大于本改动引入的几微秒延迟,所以它不会反过来变 flaky。
  • 新测试是承重的,不是摆设。 把修复回退后,注入的 cancel 会在调用方 stage 完成之前阻塞延时线程,于是 join() 一直卡到 5 s 保护超时,cancelSawCallerReturn 为 false——PR 给出的"修复前"结果(5.675 s、expected: <true> but was: <false>)正是这条路径的产物。有修复时,cancel 在另一个线程上等待,会看到 latch 已经 countDown。这个断言只可能因为正确的原因而通过。测试替身也正确地返回了 Redirect.NEVER,这一点很关键:acknowledgeCsi 在到达 post() 之前,会对任何其他 redirect 策略抛 IllegalArgumentException。
  • 没有多余的替身表面。 十二个 HttpClient 抽象方法全部实现;十个新 import 全部被使用(本模块 checkstyle 只启用了两项 import 检查,UnusedImports 是其中之一,ImportOrder 是注释掉的);post() 里的 exchange 就是 sendAsync 返回的那个对象,所以生产路径调用的正是被覆写的 cancel()。
  • 没有文档漂移。 docs/ 里没有任何地方描述 deadline-cancel 的线程模型;BoundedBodySubscriber 的 javadoc 依然成立——它说的是 stalled body 由 stage deadline 兜底,而本改动没有改变这一点。

非阻塞观察(gate 评论里已经提过,这里重复只是为了让它们贴着代码可见):cancel 现在跑在 ForkJoinPool.commonPool() 上(并行度 availableProcessors - 1,2 核 runner 上就是一个线程),而 client.executor() 能把影响范围限定在拥有这次 exchange 的 client;另外 KubernetesHttpRuntimeClient.exchangeBytes 保留了完全相同的同步结构。两者相对现状都不是回退——JVM 全局唯一的延时线程是这两者里更差的那个。两处重复的三行注释在这里是合理的,因为 why 是一个 JDK 完成顺序上的微妙之处,否则这个改动看上去像什么都没做。

测试证据

这是无人值守的 CI 运行,所以审查过程中没有构建或执行任何代码——绝不运行 PR 派生的代码。下面的证据来自本 PR 自己的 CI,通过 API 读取被审查 commit 的结果。829c0d29e4bc822225405411027d9aa2b0033cf0 上没有任何 check 的结论是 failure,所以没有失败日志可以引用。

关键的保留意见:修复还没有在它真正该被验证的地方得到确认。 windows-latest / Java 21——这个测试唯一失败过的 job,也是唯一能证明 flake 消失的平台——仍在 queued;macos-latest / Java 21 同样在排队;Linux 的 Java job 处于 in_progress。目前已 completed 的都是编排类 job(authorize、assign、label、Classify PR、Flyway migration version uniqueness);Test (macos-latest/windows-latest, Node 22.x) 和集成测试条目是 skipped,对纯 Java 的 diff 来说这是预期的。所以当前的绿色是真实的,但对这个改动说明不了什么。CI 结束后,finalize job 会就地重写上面的表格区域。

沙箱验证可以补上 CI 目前补不了的那一环:@qwen-code /verify——用来确认新增的 deadlineFailureDoesNotWaitForTheExchangeCancel 确实是承重的,也就是把两处 whenCompleteAsync 改回 whenComplete 时它会失败、保留时它会通过。这个 A/B 正是本 PR 的全部主张,而在已修复的代码树上跑出一套绿色,并不能把它和"改不改都通过"的测试区分开。作者说明回退后该测试在 5.675 s 失败并报 expected: <true> but was: <false>;这是作者的陈述,本次没有独立复跑。另外,flake 本身只在 windows-latest / Java 21 上可观测,而它仍在排队——在它于本 commit 上变绿之前,"Windows flake 已修复"依赖的是作者报告的 Linux 和 macOS 计时加上机制推导,而不是这份代码在 Windows 上的一次运行。

— Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 829c0d29e4bc822225405411027d9aa2b0033cf0 · re-run with @qwen-code /triage

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

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 CompletableFuture propagation behaviour, and that behaviour checks out against the actual call shape (post(...).thenApply(...), cleanup registered before the caller's dependent). The fix is two tokens plus a comment at each site. The test injects a slow cancel and asserts an ordering, which is the only way to pin something like this; a test that merely re-ran the flaky one and hoped would have been worthless. And the author resisted the tempting over-fix: no executor abstraction, no retry policy, no drive-by refactor of the neighbouring KubernetesHttpRuntimeClient site that has the same shape — that stayed a noted follow-up instead of widening the diff.

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: whenCompleteAsync reads as "off the caller's path", but where it lands is an implementation default, and the common pool is JVM-wide with parallelism availableProcessors - 1. The comment above each site explains why the cancel moved off the delay thread; it doesn't say which thread it moved to, or that this is a deliberate trade rather than an oversight. One extra clause naming the pool would make the choice durable. Non-blocking — the trade is defensible and strictly better than the single delay thread it replaces.

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 windows-latest / Java 21 now passes well under its 3 s bound — because that job is still queued on this commit. That is a CI-timing gap, not a doubt about the change, and it is exactly what deferring the approval closes: the mechanism is platform-independent (the cancel leaves the caller's path regardless of how long it takes), so Linux going green plus the ordering test is strong support, and the Windows job is confirmation rather than discovery.

Two PR CI workflow runs are still in flight on this commit, so approval is deferred until CI lands green on 829c0d29e4bc822225405411027d9aa2b0033cf0. The finalize job posts the commit-pinned approval once every check completes green, and withholds it if anything lands red or the head moves. Nothing here needs a maintainer decision — the follow-up on KubernetesHttpRuntimeClient.exchangeBytes can be its own PR whenever someone wants it.

中文说明

Confidence: 4/5 —— 机制已经对照代码核实,回归测试只可能因为正确的原因而通过,改动也已经是这个修复能有的最小形态;两点保留是 common pool 这个落点,以及 Windows CI 还没有出结果。

退一步看:这是个好 PR,也是我愿意接手维护的那种。诊断不是含糊带过的——它点明了 CompletableFuture 一个具体的传播行为,而这个行为对照真实的调用结构是成立的(post(...).thenApply(...),清理注册在调用方依赖之前)。修复是每处两个 token 加一段注释。测试注入了一个慢 cancel 并断言顺序,这是唯一能钉住这类问题的方式;如果只是把原来那个 flaky 测试再跑一遍碰运气,就毫无价值。而且作者忍住了过度修复的诱惑:没有引入 executor 抽象,没有加重试策略,也没有顺手重构旁边结构相同的 KubernetesHttpRuntimeClient——那一处被记为后续项,而不是把 diff 撑大。

我的独立方案和 diff 收敛到了同一个做法,所以我没有藏着一个更简的路径。如果六个月后要我维护这段代码,我会感谢作者,只会有一句轻微的抱怨:whenCompleteAsync 读起来是"离开调用方路径",但它落到哪里是一个实现默认值,而 common pool 是 JVM 全局的、并行度为 availableProcessors - 1。两处上方的注释解释了 cancel 为什么要离开延时线程,但没有说它搬到了哪个线程,也没有说这是一个有意的取舍而不是疏漏。多加一个从句点名这个池,能让这个决定更耐放。这不构成阻塞——取舍是站得住的,而且严格优于它所替换的那个唯一延时线程。

关于我最在意的证据问题:我核实了问题确实存在(三个真实的失败 run、只在 Windows 出现、外加一个最小复现),也通过推演回退与修复两条路径核实了测试是承重的,而不是照搬作者给出的数字。我没能核实的是这个 PR 最终要达成的那件事——windows-latest / Java 21 现在是否远低于 3 s 上限通过——因为在这个 commit 上该 job 仍在排队。这是 CI 时序上的缺口,不是对改动本身的怀疑,而这恰恰是推迟 approve 要闭合的东西:机制与平台无关(无论 cancel 耗时多久,它都离开了调用方路径),所以 Linux 变绿加上这个顺序测试已经是有力的支撑,Windows job 是确认而不是发现。

这个 commit 上还有两个 PR CI workflow run 在跑,因此 approve 推迟到 CI 在 829c0d29e4bc822225405411027d9aa2b0033cf0 上全绿之后。finalize job 会在所有 check 变绿后发出绑定该 commit 的 approve;若有 check 变红或 head 移动,则不会发出。这里没有需要维护者拍板的事项——KubernetesHttpRuntimeClient.exchangeBytes 的后续项,等有人想做的时候单独开一个 PR 就行。

— Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 829c0d29e4bc822225405411027d9aa2b0033cf0 · re-run with @qwen-code /triage

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

评审结论:未发现本次变更引入的阻塞性问题(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 文档。

@wenshao

wenshao commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator Author

🔍 Local Maintainer Verification — PR #13562

Verdict: ✅ merge-ready — 754 assertions green, 0 failures (732 full-suite + 20 targeted-repeat + 1 mutation kill + 1 static gate; tally in assertions.json, verdict in verdict.txt).
Verified head: 829c0d29e4bc822225405411027d9aa2b0033cf0 · Base (merge-base with main): 4970bfa172077147adf7abc226e3d0462b179ac5

🇨🇳 中文摘要

结论:✅ 可合并 — 共 754 项断言全部通过、0 失败(全套件 732 + 定向重复 20 + 变异击杀 1 + 静态门禁 1)。

  • 本 PR 只改一个生产文件(HttpRuntimeTransport.java 中两处 whenComplete → whenCompleteAsync)并新增一个确定性回归测试。本地在 macOS + JDK 21.0.12 + Maven 3.9.16 上、于 PR head(829c0d29)的隔离 worktree 中验证。
  • 红/绿变异证明:把生产文件回退到 merge-base(即本次修复前)后,新测试 deadlineFailureDoesNotWaitForTheExchangeCancel 以 PR 描述的精确签名变红(5.8s 后 the deadline failure waited for the exchange cancel ==> expected: <true> but was: <false>);恢复 head 后同一测试类 5 连跑全绿(新测试 0.67–0.86s,原 flaky 测试 0.74–0.83s,远低于 3s 上限)。
  • runtime-broker 全套件:735 个测试,0 失败 0 错误,3 个跳过(macOS 环境门控;PR 作者在 Linux 上为 5 个跳过),含 HttpRuntimeTransportTest.closesTheConnectionOnTheDeadlineAndOnCallerCancel 通过——连接关闭行为未受影响。
  • checkstyle:check 干净;变异回退后 worktree 已恢复(git status 干净)。
  • CI 交叉核对:windows-latest / Java 21(原 flaky 任务)4m33s 通过;Hosted process fault gates 任务逐步骤确认全部 success(含第 12 步 Runtime Broker fault gates 与全部 failover E2E,非跳过)。
  • 未覆盖:Windows 本地复现(无 Windows 机器,由 CI 覆盖);JDK cancel 在 Windows 上偶发 ~3s 的根因(PR 已声明超出范围,且修复把它移出调用方路径,与根因无关)。

Environment

macOS (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: git diff <base> HEAD --stat = 2 files, +123/−2).

What was verified

Gate Result Evidence
Full runtime-broker suite (mvn test) on head ✅ 735 tests, 0 failures, 0 errors, 3 skipped (env-gated; PR reports 5 skipped on Linux) head-full-suite.log
ManagedCsiAcknowledgementHttpTransportTest ×5 consecutive runs on head ✅ 4/4 green each run (20 executions) head-targeted-run{1..5}.log
New regression test deadlineFailureDoesNotWaitForTheExchangeCancel (head) ✅ 0.67–0.86s per run surefire XML
Formerly flaky totalDeadlineIncludesAResponseBodyThatNeverFinishes (head) ✅ 0.74–0.83s per run — far under the 3s bound surefire XML
Connection-close behavior HttpRuntimeTransportTest.closesTheConnectionOnTheDeadlineAndOnCallerCancel ✅ green in full suite (1.143s); class 69/69 green surefire XML
mvn checkstyle:check ✅ BUILD SUCCESS checkstyle.log
Mutation: production file reverted to merge-base ✅ killed — new test red after 5.8s with the exact predicted signature; other 3 tests still green mutant-run.log, live capture below
Worktree restored after mutation ✅ git status clean, both whenCompleteAsync back mutation log tail

A/B red–green proof

The PR touches exactly one production file, so the mutation matrix is that one file: reverted in place to the merge-base version (whenCompleteAsync count 2 → 0), reran the regression class, then restored (git status verified clean afterwards).

  • Base (mutant) — red, with the bug's own signature: Tests run: 4, Failures: 1 — deadlineFailureDoesNotWaitForTheExchangeCancel fails after 5.839s: org.opentest4j.AssertionFailedError: the deadline failure waited for the exchange cancel ==> expected: <true> but was: <false>. This reproduces the PR's stated before-behavior (5.675s) almost exactly.
  • Head — green: same class 4/4, new test ~0.7s. The mutant is killed only by the new regression test (the other three stay green on base), so the test provably pins the fixed behavior and nothing else.

Code review notes

  • Scope: minimal and surgical — two whenComplete → whenCompleteAsync changes (attest ~L219, post ~L1109) plus an explanatory comment at each site; deadline semantics, error code (managed_runtime_unavailable), and the connection close itself are untouched, consistent with the PR's claim.
  • Why the fix is correct: on a deadline, the stage completes on the JVM-wide CompletableFuture delay thread; the inline cancel previously ran there and ahead of the caller's continuation, so a slow JDK HttpClient cancel (observed ~3s on Windows) inflated the caller's wall-clock and stalled every other orTimeout in the process. The async continuation moves the socket close off both the caller's completion path and the shared timer thread.
  • The new test is deterministic, not timing-based: the injected StalledExchangeClient never answers and blocks inside cancel() until the caller has returned (5s guard); it asserts ordering (caller released before cancel finishes), so it cannot flake the way the wall-clock assertion did.
  • Downstream impact: callers of attest/post only observe the returned stage; nothing depends on the cancel running inline. The cancel still runs on every error/cancel path — just a few µs later, on the common pool.

CI cross-check

windows-latest / Java 21 (the formerly flaky job) ✅ 4m33s on this PR; macos-latest, ubuntu-latest (Java 11/17/21) all ✅. Hosted process fault gates / MySQL 8.4 / Java 21 ✅ 33m — per-step conclusions verified: step 12 Run Runtime Broker fault gates and all owner-failover E2Es are success, not skipped.

Not covered

  • Windows local run (no Windows box here) — the platform where the flake appeared; covered by this PR's green windows-latest / Java 21 CI lane.
  • Root cause of the ~3s JDK cancel on Windows — explicitly out of scope per the PR; the fix removes it from the caller's path regardless of cause.
  • Non-runtime-broker modules and the TS CLI are untouched by this diff, so their gates were not run locally (CI's JS lanes are green/skipped per path filtering).

Methodology

  1. Detached worktree at the PR head (829c0d29); merge-base with main (4970bfa1) confirmed as the base; the touched production file is identical between merge-base and current main.
  2. mvn test full module suite + 5× targeted class runs on head; per-test timings read from surefire XML, not console estimates.
  3. Mutation = in-place revert of the single production file to base, rerun, restore, verify clean.
  4. checkstyle:check as the static gate.
  5. Live terminal captures (node-pty → xterm.js) of the head run, the mutant run, and the recorded full-suite summary; artifacts under tmp/pr13562-verify-20261007/.

Evidence captures

Head — regression class green (live run):

head targeted run

Mutant (base transport) — red with the predicted signature (live run):

mutant red run

Full suite on head — 735/0/0/3 + key per-test timings:

full suite summary


Local verification performed by @wenshao's maintainer harness; artifacts (head-full-suite.log, mutant-run.log, head-targeted-run{1..5}.log, checkstyle.log, assertions.json, verdict.txt, PNGs) retained under tmp/pr13562-verify-20261007/.

@qwen-code-review-bot qwen-code-review-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 — CI landed green after the review. ✅

@wenshao
wenshao enabled auto-merge October 7, 2026 05:11

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

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 an HttpClient thread, never on the Delayer. Their work is result.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.
  • result and returned are distinct objects, as the cancels assume. orTimeout returns this, so result.orTimeout(...) is still result, and .handle(...) produces the new future bound to returned. result.cancel(false) therefore releases the source rather than the stage the caller holds, and returned.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 exchange and result — and nothing downstream reads it. Cancelling a dependent stage does not cancel its source in CompletableFuture, so the explicit result.cancel(false) is still doing real work; it just does it microseconds later. If the exchange completed normally in the meantime, result.complete/completeExceptionally on an already-completed or cancelled future is a no-op returning false, and the action's own error != null guard means it does nothing at all on the success path.
  • Ordering is now weaker, and nothing depends on it. With whenComplete the close happened-before the caller observed the failure; with whenCompleteAsync it 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.

@wenshao
wenshao added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit 28512f1 Oct 7, 2026
92 of 93 checks passed
yc2bgr8 pushed a commit to yc2bgr8/qwen-code that referenced this pull request Oct 7, 2026
…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]>
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