Repository navigation
fix(runtime-broker): close cross-process release race and harden scheduling - #13214
Conversation
…duling The release decision and the no-active-execution check now commit in one transaction under the Session row lock that admission also takes, so two Broker processes sharing a database can no longer interleave an admission into a release. Lease renewals move off the single coordination thread onto a dedicated two-thread pool, v3 result polling backs off exponentially (100 ms doubling to a 5 s cap, with a configurable window), and automatic UNKNOWN observations share the freshest lookup for a one-second cooldown instead of fanning every poll through to the worker. The HTTP listener refuses non-loopback bind addresses unless the deployment opts in (allow-non-loopback), non-durable worker shutdown escalates destroy() through a bounded grace window to destroyForcibly() with a JVM exit hook that also covers workers still in their ready handshake, and a LOST reclaim loops the bounded 100-row recovery passes until the generation drains instead of failing past 300 sessions. Refs #13183
Verification reportReproduce-first, then fix, then verify — same Maven harness throughout (JDK 21, Maven 3.9.16, H2 in
中文先复现、再修复、后验证——全程同一 Maven 工具链(JDK 21、Maven 3.9.16、H2
|
…rves, and reclaim bounds Review round on #13214. The cooldown cache-hit path now replays a settled reconciliation whole instead of pairing it with a caller's stale UNKNOWN record; mutation responses (:start/:cancel) bypass the cooldown so their answers describe post-mutation truth; the v3 result window has a one-second floor (a suffix-less config value binds as milliseconds); the LOST drain loop measures its baseline and stops after 16 bounded passes, answering runtime_broker_runtime_lost for larger generations so a later reclaim resumes; the redundant pre-flight execution check is dropped from releaseSession (the transition's own transaction answers the same 409; the cooldown eviction consults the service clock; the dead lookupOnce overload is removed; and the design documents record the mechanism in both languages, with a new hardening design for this change. Tests: contract coverage for beginSessionRelease on both repository backends, a service-level cross-process release race, the settled-answer replay guard, per-execution cooldown scoping, the v3 window bound, the dispatch renewal pool pin, the provision-during-close guard, the pass budget, and the --ignore-term wedge; shared fixtures are deduplicated and the new classes carry suite-level timeouts. EOF )
Round-2 review dispositions (commit 34b3f07)All 21 findings were addressed; each new guard now carries a mutation-proven test (mutant red, fix green). Fixed:
Declined with rationale:
Verification: 中文说明第二轮评审的 21 条发现已全部处理(commit 34b3f07),每个新守卫都有变异验证过的测试(变异红、修复绿)。 已修复:R1-1(Critical,缓存命中回放规则)、R1-2(窗口 1s 下限)、R1-4(mutation 响应不走冷却)、R1-5(实测基线 + 16 趟预算;orTimeout 未采纳——排空是调用方线程上的同步 JDBC,超时只会让调用方解脱而循环仍持有 claim,预算才是诚实的边界)、R1-6(每个守卫的钉住测试)、R1-7(按执行隔离)、R1-8(删冗余预检)、R1-9(删死重载)、R1-10(双语设计文档)、R1-11(夹具去重)、R1-12(删失效控制测试)、R1-13(精确执行数,顺带修正了夹具的 PREPARED/EXECUTING 语义)、R1-14(双向断言)、R1-15(只测 close())、R1-16(有界等待 + 类级 @timeout)、R1-17(楔子自身钉住)、R1-18/19(措辞与日志留存)、R1-20(驱逐尊重服务时钟)、R1-21(常量包可见)。 未采纳并说明:R1-3(5s 封顶是 issue 要求的负载/时延取舍;末间隔内到达的结果转 UNKNOWN 后仍可经既有对账路径恢复,与改动前 100ms 轮询的语义一致)。 验证:runtime-broker clean 全量 587 个测试,唯一失败是已确认的存量 flake( |
…ker-hardening # Conflicts: # packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRepository.java
The --ignore-term wedge test is POSIX-only (Windows CI has no SIGTERM to swallow) and gains the mirror arm proving a default worker exits on destroy(). A cooled observation now serves the fresher of the caller's snapshot and the lookup's own re-read while both are UNKNOWN. The :start mutation response's cache bypass is pinned by a discriminating test, the budget test now proves the next reclaim resumes, and the provision-during-close test matches the guard's exact message and stops at pool shutdown. An orphaned import is dropped.
Round-3 review dispositions + merge (commit 3710ef7, merged with main at a011f66)The branch merged Fixed:
Declined with rationale:
Deferred items from the round-3 body (v3-window forwarding witness, Verification on the merged tree ( 中文说明第三轮评审处置 + 与 main 的合并(提交 3710ef7,合并点为 a011f66): 冲突为 已修复:R2-1(Critical,Windows 无 SIGTERM,wedge 测试加 未采纳并说明:R2-3(冷却窗口按服务时钟定义,与服务内其它所有截止期同源;时钟回拨拉长窗口是该定义的一致行为;驱逐任务只是卫生措施,读侧截止判定才是权威,因此驱逐不失效不会提供过期答案;缓存项按 execution id 有界)。 本轮声明为"不要求处理"的延后项(v3 窗口传递见证、shutdownNow 见证、harness 构造器清理)保持原样。 验证(合并树 + 本轮):runtime-broker clean 全量 620 测试,唯一失败为基线同现的存量 flake;managed-agent-server 邻近 17/17 绿;R2-2 与 R2-5 的钉住测试均在变异下变红、修复后变绿。 |
An undirected and an adversarial pass over the merged branch head found one Critical and ten Suggestions. Fixed here: - The SIGTERM mirror test destroyed the fake worker without reading its ready line first. The fake installs its handler in top-level script flow and writes the ready line from the listen callback, so a destroy issued straight after spawn lands before any handler exists and the worker dies through SIGTERM's default disposition: the assertion passed whether or not the default handler worked. It now waits for the ready line. Wedging the default arm keeps the fixed test red while the old body stayed green three runs out of three. - The v3 result-poll backoff cap drops from 5s to 2s, since the cap only bounds how late a finished result is picked up, and the polling test now pins the first capped gap into [1.9s, 2.9s) so the cap cannot drift back silently (an uncapped doubling schedules 3.2s there). - The pass-budget reclaim test raced its own deadline: a 3s lease gives a 12s operation deadline inside the test's 60s wait, so a slow runner could answer on the deadline instead of on the 16-pass budget and the assertion would still see runtime_broker_runtime_lost. Both reclaiming services now take a 10s lease (40s deadline) through a service() overload, and the drain comment states the traced arithmetic: the first site releases nothing before the loss evidence exists, the next two drain 16 * 100 rows each, and the ~1.6k that remain stop the reclaim. - The deployment README documents the two knobs this branch added (allow-non-loopback and the v3 result window) with their defaults. - The hardening design doc, in both languages, no longer claims a stalled renewal blocks "at most one pool thread" (a two-thread pool saturates at two), states what the split actually buys, and describes the cooldown replay rule the code implements: a cached record that is no longer UNKNOWN is replayed whole, otherwise the higher version of the two wins. Declined: the reported service leak on server close cannot happen because the server's close already cascades to the service; relaxing the in-memory release gate was tried and reverted, since it is the sibling of the JDBC backend's same-DataSource precondition rather than drift from it (alignment stays in #13204). Suites green on top: runtime-broker 627 tests / 0 failures / 2 skipped with checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19 against the reinstalled jar.
Self-audit round 1 (undirected + adversarial) — dispositionsRan an undirected pass and an adversarial pass over the branch head after re-merging FixedC1 — Mutation proof: with the fake's default arm wedged ( S3 — v3 result-poll cap 5 s → 2 s. The cap only exists to bound how late a finished result is picked up, and 5 s is coarser than the polling it replaces. Mutation proof: restoring S4 + Adv2 — the pass-budget test raced its own deadline. The comment also miscounted the drain: it claimed three drain calls release 16 bounded passes each. Traced behaviour is that the first site returns before releasing anything (the loss evidence is not written yet), sites two and three drain 16 × 100 rows each, and the ~1.6k rows that remain stop the reclaim with LOST. The comment now says that. Side effect: the test went from ~23 s to ~2.2 s, because it no longer waits for a deadline to lapse. S7 — the deployment README documents the two new knobs ( S2 + Adv3 — design doc corrected, both languages. The renewal-pool paragraph claimed "one stalled renewal blocks at most one pool thread"; with a two-thread pool, two parked ticks saturate it. What the split actually buys is that coordination work no longer queues behind a parked tick and that DeclinedS5 — "the service outlives the HTTP server": false positive. S6 — relaxing the in-memory release gate: declined after trying it. Adv-S1 — injecting a clock into the HTTP fixture: declined. The cooldown assertions hold with roughly an order of magnitude of wall-clock margin, and that fixture already sleeps for the same purpose elsewhere; a clock seam there costs more than it buys. Verification
|
Real-stack verification — head
|
| PR claim | Real-stack result | |
|---|---|---|
| Release and admission are decided in one transaction across processes | Two Broker JVMs on one MySQL, release and admission fired together each round (300 rounds). Rounds ending with the Session RELEASED while an execution was still active: base 70/300, head 0, cand 0. With +5 ms per DB hop: base 89/200, head 0/200. On head every round has exactly one winner: 409 runtime_session_busy or runtime_admission_closed. |
✅ |
| One stalled renewal no longer fences unrelated bindings | One execution's dispatch renewal parked in a real InnoDB lock wait (45 s FOR UPDATE on its primary key), production lease 30 s. A healthy execution on another binding: base → UNKNOWN (it ran, but its result was thrown away); head → SETTLED/success. |
✅ |
| Non-loopback listen addresses are refused unless opted in | Spring startup matrix. Head fails startup with an actionable message for 0.0.0.0, the LAN IP and an unresolved name, and starts with ALLOW_NON_LOOPBACK=true. localhost and ::1 still start. Base listened on *: and on the LAN IP. |
✅ |
v3-result-window is configurable, and a suffix-less value is refused |
500 → startup fails (must be at least PT1S); abc → fails; 1s and 45m → start. |
✅ |
Wedged worker escalates to destroyForcibly; the exit hook covers workers still starting |
Spring SIGTERM with the worker frozen (SIGSTOP): head kills it after 5.1 s; on base it outlives the Broker. Library-embedded JVM SIGTERM (no close()): head kills workers in ≈0.1 s, both healthy ones and ones still in the ready handshake. Base orphans them: across all rig runs, 14 base workers were left with ppid 1, 0 on head. |
✅ |
| LOST reclaim drains past 300 sessions | Simulated power loss with 303 Sessions (durable store + trusted reboot recovery). Base: first warm → 503 runtime_broker_runtime_lost with 300 released, the second warm recovers. Head: one warm → 200, all 303 released. |
✅ |
| UNKNOWN observation cooldown | The cooldown is correct where it applies, but it never applies to the shipped client (F2). |
Test suites:
- runtime-broker
mvn verify: 627 tests, 0 failures (2 skipped); SpotBugs 0; Checkstyle 0. mysql-integrationfailsafe profile (not run in the PR, run here against MySQL 8.4.7): 7/7, including the newverifyBeginSessionReleasecontract.- managed-agent-server
EmbeddedRuntimeBrokerTest+ManagedAgentPropertiesTest: 19/19.
F1 — bot R3-1 confirmed: a release retry over a RELEASING row ignores a live execution (fix before merge)
Setup: a real worker over the HTTP face, a Session row seeded as RELEASING with a PREPARED execution on the same binding and generation. That is what a pre-fix peer leaves behind when its unguarded check-then-act lets an admission in (card 1) and its worker release then fails, e.g. during a mixed-version rolling deploy.
| arm | POST :release |
Session after | execution after | worker told to release |
|---|---|---|---|---|
| base | 409 runtime_session_busy |
RELEASING | PREPARED | 0 |
| head | 200 released=true |
RELEASED | PREPARED | 1 |
| cand | 409 runtime_session_busy |
RELEASING | PREPARED | 0 |
Head's transitionSessionToReleasing short-circuits on RELEASING without asking the execution repository. The candidate adds that check inside the short-circuit; it is the bot's suggestion (candidate-r31.patch, 9 lines plus one test). With the candidate:
- A
RELEASINGrow with nothing active still releases (200), so the idempotent retry is kept. - The new test
releaseRetryOverAPersistedReleasingRowRefusesALiveExecutionfails without the 9 lines (Expected CompletionException … nothing was thrown) and passes with them. - The full module suite runs 628 tests with 0 failures; SpotBugs 0, Checkstyle 0.
- The 300-round race still has 0 contradictions.
F2 — the cooldown does not reach the shipped poller (non-blocking, bot R3-3)
The only shipped path that polls an UNKNOWN execution repeatedly is HostedWorkspaceBroker.execute(…, waitForUnknown = mcp !== undefined). It sends GET …?reconcile=true every 250 ms, and reconcile=true bypasses the cooldown by design.
I made an execution UNKNOWN for real (the worker's /execute response is dropped while the shell keeps running) and drove the real client for 20 s:
| client path | head (GETs / worker status calls) | base (GETs / worker status calls) |
|---|---|---|
MCP session (?reconcile=true) |
64 / 29 | 61 / 28 |
| non-MCP session | 1 GET, then 409 runtime_broker_execution_unknown |
same |
| 20 rapid plain GETs on a v2 UNKNOWN | 0 status calls | 0 |
At ≈1.4 status calls/s over the 630 s MCP window, that is still about 900 status calls per execution on both arms — the #13183 symptom. The v3 and provider plain-GET path the cooldown does cover is pinned only by unit tests here; the shipped v3 client stops at its first UNKNOWN. Options: apply a (shorter) cooldown to reconcile=true as well, or have the client back off. Otherwise, narrow the PR text so it doesn't claim the #13183 fan-out is fixed.
F3 — the two-thread renewal pool absorbs exactly one parked renewal (non-blocking, as the design doc says)
With two parked renewals on two bindings, head's healthy execution also ended UNKNOWN, the same as base. The pool helps against one stuck row; it does not help against a database-wide stall, where every renewal's JDBC call parks together.
Rig deviation: the worker request timeout was 120 s (production 30 s), so a v2 call can outlive one lease. It stands in for long v3 executions, whose dispatch claims are renewed the same way.
Notes (no action needed)
- N1: the shutdown escalation and the exit hook apply only to non-durable provisioners. Since feat(managed-agent): default durable local-process and trusted reboot recovery on #13211, Spring defaults to durable local-process (Linux), where workers are adopted on restart, so these paths are inert in the default deployment. A SIGKILLed Broker cannot run any hook on either arm, and the worker survives.
- N2: the drain loop needs stop evidence. After a plain worker crash under the non-durable provisioner, the generation stays LOST with
JOURNAL_LOSTon both arms; that is the pre-existing writer-domain rule. - N3:
V3_RESULT_WINDOW=45mis accepted, but the TS client's own v3 deadline is fixed at 30 min (bot R3-4). - N4:
HOST=127.0.0.2fails on macOS with aBindExceptionon both arms becauselo0has no such alias. Head's loopback check accepts it.
Evidence: assets-pr13214@e8f18ecf. It holds the harness (RigBroker/RigAdmitter JVM mains over the module's test-jar, the drivers, the Spring matrix and lifecycle scripts), the raw results, and the candidate patch.
中文版
真实环境验证 — head af6691ce1f
结论:各项修复在真实栈上成立。合并前先修一处:bot R3-1(仍未解决)端到端复现。 对已处于 RELEASING 的 Session 行重试释放,会在 execution 仍活跃的情况下完成释放,而 base 会拒绝这次重试。bot 建议的 9 行修复加一个回归测试即可关闭该问题,且实测中其他行为都不受影响(见下)。其余发现均不阻塞合并。
验证全程 head 未变。main 已前进到 1a4de7486a(#13206,只改 web-shell,不涉及 sdk-java),试合并干净。CI 除仍在运行的 review-pr 外全绿。
环境
- 三臂: head
af6691ce1f、base5130c1a734(merge-base)、cand(head + R3-1 修复)。每臂用独立 Maven 仓库构建,并用javap核对了字节码。 - 数据库: MySQL 8.4.7(InnoDB,REPEATABLE-READ,UTC),全程未用 H2。
- Broker 进程: 每个 Broker 是独立 JVM,跑生产
RuntimeBrokerService、JDBC 仓库和 HTTP 面。worker 是打包的node dist/cli.js managed-runtime-worker;本 PR 不改 TS,dist取自 merge-base 构建。 - Spring: 真实
managed-agent-serverjar,只通过文档列出的环境变量配置。 - TS 客户端: 直接驱动随产品发布的 Hosted Harness 客户端(
hosted-workspace-broker.ts)。
| PR 主张 | 真实栈结果 | |
|---|---|---|
| 跨进程的释放与准入在同一事务内判定 | 两个 Broker JVM 共享一个 MySQL,每轮同时发起释放和准入(300 轮)。结束时 Session 为 RELEASED 而 execution 仍活跃的轮数:base 70/300,head 0,cand 0。每跳 +5 ms 延迟时:base 89/200,head 0/200。head 每轮都恰好只有一方胜出:409 runtime_session_busy 或 runtime_admission_closed。 |
✅ |
| 一个卡住的续约不再围栏无关 binding | 让一个 execution 的派发续约卡在真实 InnoDB 锁等待中(对其主键 FOR UPDATE 45 s),生产租约 30 s。另一个 binding 上的健康 execution:base → UNKNOWN(命令跑完了,结果被丢弃);head → SETTLED/success。 |
✅ |
| 默认拒绝非回环监听地址,需显式开启 | Spring 启动矩阵:head 对 0.0.0.0、局域网 IP 和无法解析的主机名都启动失败并给出可操作的提示;设 ALLOW_NON_LOOPBACK=true 后可启动;localhost、::1 照常启动。base 则实际监听了 *: 和局域网 IP。 |
✅ |
v3-result-window 可配置,无单位的值被拒绝 |
500 → 启动失败(must be at least PT1S);abc → 失败;1s 和 45m → 可启动。 |
✅ |
卡死的 worker 升级为 destroyForcibly,退出钩子覆盖仍在启动的 worker |
Spring 收到 SIGTERM 时 worker 已被冻结(SIGSTOP):head 在 5.1 s 后杀掉它;base 上它比 Broker 活得更久。 嵌入式库 JVM 收到 SIGTERM(未调用 close()):无论健康的 worker 还是仍在 ready 握手的 worker,head 都在约 0.1 s 内杀掉;base 则留下孤儿:整个装置跑完后 base 留下 14 个 ppid 为 1 的 worker,head 为 0。 |
✅ |
| LOST 回收能清空超过 300 个 session | 303 个 Session 模拟断电(durable store + 受信重启恢复)。base:第一次 warm 返回 503 runtime_broker_runtime_lost,释放了 300 个,第二次 warm 才恢复。head:一次 warm 返回 200,303 个全部释放。 |
✅ |
| UNKNOWN 观测冷却 | 在它生效的路径上是正确的,但随产品发布的客户端从不走这条路径(F2)。 |
测试套件:
- runtime-broker
mvn verify: 627 个测试,0 失败(2 个跳过);SpotBugs 0;Checkstyle 0。 mysql-integrationfailsafe profile(PR 中未跑,这里对 MySQL 8.4.7 跑):7/7,包含新增的verifyBeginSessionRelease契约。- managed-agent-server
EmbeddedRuntimeBrokerTest+ManagedAgentPropertiesTest: 19/19。
F1 — bot R3-1 成立:对 RELEASING 行重试释放会忽略活跃 execution(建议合并前修)
装置:真实 worker 经 HTTP 面,预置一个 RELEASING 的 Session 行,同一 binding 和代际上有一个 PREPARED 的 execution。这正是修复前的 peer 留下的状态:它未加保护的先查后改放进了一次准入(图 1),随后其 worker release 又失败,例如在新旧版本混跑的滚动发布中。
| 臂 | POST :release |
Session 之后 | execution 之后 | worker 收到 release |
|---|---|---|---|---|
| base | 409 runtime_session_busy |
RELEASING | PREPARED | 0 |
| head | 200 released=true |
RELEASED | PREPARED | 1 |
| cand | 409 runtime_session_busy |
RELEASING | PREPARED | 0 |
head 的 transitionSessionToReleasing 遇到 RELEASING 直接短路返回,不查 execution 仓库。候选补丁在短路分支里补上这项检查,即 bot 的建议写法(candidate-r31.patch,9 行加一个测试)。应用候选补丁后:
- 没有活跃 execution 的
RELEASING行重试仍返回 200,幂等重试得以保留。 - 新测试
releaseRetryOverAPersistedReleasingRowRefusesALiveExecution去掉这 9 行时失败(Expected CompletionException … nothing was thrown),加上后通过。 - 整个模块套件 628 个测试 0 失败;SpotBugs 0,Checkstyle 0。
- 300 轮竞态仍是 0 次矛盾。
F2 — 冷却覆盖不到随产品发布的轮询方(不阻塞,bot R3-3)
随产品发布的代码里,唯一会反复轮询 UNKNOWN execution 的路径是 HostedWorkspaceBroker.execute(…, waitForUnknown = mcp !== undefined)。它每 250 ms 发一次 GET …?reconcile=true,而 reconcile=true 按设计绕过冷却。
我真实地造出一个 UNKNOWN execution(丢弃 worker 的 /execute 应答,shell 继续运行),用真实客户端驱动 20 s:
| 客户端路径 | head(GET 次数 / worker status 调用) | base(GET 次数 / worker status 调用) |
|---|---|---|
MCP 会话(?reconcile=true) |
64 / 29 | 61 / 28 |
| 非 MCP 会话 | 1 次 GET,随后 409 runtime_broker_execution_unknown |
相同 |
| 对 v2 UNKNOWN 连续 20 次普通 GET | 0 次 status 调用 | 0 |
按约 1.4 次 status/s 计,在 630 s 的 MCP 观测窗口内每个 execution 仍有约 900 次 status 调用,两臂相同,即 #13183 描述的症状。冷却真正覆盖的 v3/provider 普通 GET 路径,在这里只有单测钉住;而随产品发布的 v3 客户端遇到第一次 UNKNOWN 就停止。可选做法:给 reconcile=true 也加一个(更短的)冷却,或让客户端退避;否则请收窄 PR 文字,不要声称已修复 #13183 的穿透问题。
F3 — 双线程续约池恰好只能吸收一个卡住的续约(不阻塞,与设计文档一致)
两个 binding 上各有一个卡住的续约时,head 上的健康 execution 同样变为 UNKNOWN,表现与 base 相同。线程池能对付单行卡住,对整库停顿无效,因为那时每个续约的 JDBC 调用会同时卡住。
装置偏差:worker 请求超时设为 120 s(生产为 30 s),让 v2 调用能跨过一个租约周期,用来代表长时间运行的 v3 execution,后者的派发租约以同样方式续约。
备注(无需处理)
- N1: 关停升级和退出钩子只作用于非 durable provisioner。自 feat(managed-agent): default durable local-process and trusted reboot recovery on #13211 起,Spring 默认使用 durable local-process(Linux),worker 在重启后被接管,因此这些路径在默认部署下不生效。Broker 被 SIGKILL 时任何钩子都无法运行,两臂的 worker 都会存活。
- N2: 清空循环需要停止证据。非 durable provisioner 下 worker 单纯崩溃后,两臂的代际都停在 LOST(
JOURNAL_LOST),这是既有的写者域规则。 - N3:
V3_RESULT_WINDOW=45m会被接受,但 TS 客户端自己的 v3 期限固定为 30 分钟(bot R3-4)。 - N4:
HOST=127.0.0.2在 macOS 上两臂都因lo0没有该别名而BindException;head 的回环检查本身接受该地址。
证据:assets-pr13214@e8f18ecf,包含装置(基于模块 test-jar 的 RigBroker/RigAdmitter JVM 入口、各驱动脚本、Spring 启动矩阵与生命周期脚本)、原始结果和候选补丁。
An adversarial pass over the tests and an undirected pass over the whole diff found no Critical and fifteen Suggestions; thirteen are fixed here and two are declined in the PR thread. The headline is that the cross-process stress never exercised half of its own matrix: instrumenting it shows admission won 0 of 200 rounds, because the release goes straight to the session row lock while admission first takes the placement-domain lock and does two reads. Half the rounds now hold the release until the admission has committed, and the test asserts both directions occurred, so dropping the FOR UPDATE that the whole fix rests on turns it red with two contradictions instead of passing. Three more assertions were satisfiable without the code they name: - The settled-cache replay branch could be deleted without failing its test, because the version comparison picked the same record; the stale snapshot is now pinned to the settled record's own version. - The "serve the fresher of two UNKNOWN records" rule was only covered with the cache holding the newer record, so collapsing that comparison survived the whole suite; a cancel now lands after the cached lookup too. - Deleting the renewal pool's shutdown in close() was invisible, since a tick cancels itself while a pool's threads exit only on shutdown; a new test tracks the thread IDs an in-flight dispatch starts and asserts they are gone after close. The v3 poll cap moves off the wall clock: the delay computation is extracted and asserted exactly for attempts 0-7, which tells a 2s cap from a 2.5s one in 0.2s and drops a 900ms scheduler-jitter budget from the timing test. Three tests that cannot fail on Windows now say so, since destroy() terminates outright there and --ignore-term cannot wedge a worker. The exit-hook test no longer inherits its observation window from a sleep inside the forked harness, and the provision-race test's javadoc claims only the guard it can reach: spawn-and-register atomicity is structural, inside the lifecycle lock, not observable from outside it. A refused release is now retried to prove it left the path usable, and the embedded broker's window validation covers its null and millisecond arms. Documentation that described behaviour the code does not have: the deployment README called v3-result-window a result-retention period when it is the post-dispatch polling deadline whose expiry marks the execution UNKNOWN; the new repository primitive's contract omitted the runtime_session_not_ready outcome both implementations throw; the window floor's two comments justified 1s by a poll schedule that would support 100ms; the HTTP layer claimed a mutation response describes post-mutation truth, which the pre-existing in-flight lookup merge does not guarantee; and the design doc's problem section counted three defects while its decisions listed five. Declined: a longer lease for the pass-budget test (its drain measures 2.2s against a 40s deadline, not the 12s the suggestion inferred from a comment), and rescheduling the one-shot cooldown eviction (a backwards clock jump retains one small record per affected execution, on the automatic-observe path only; rescheduling becomes a perpetual timer per entry under the injected clocks those tests use). Suites green on top: runtime-broker 630 tests / 0 failures / 2 skipped with checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19 against the reinstalled jar.
Self-audit round 2 (adversarial + undirected) — dispositionsTwo independent passes over af6691c: an adversarial one attacking the tests and the concurrency claims, and an undirected one over the whole diff. Neither found a Critical. Together they returned 15 Suggestions: 13 fixed in 20d8f48, 2 declined with measurements below. Every new or strengthened assertion was proven red against a mutant. The headline: the core race test was one-sided
Fix: half the rounds now hold the release until the admission has committed (a second latch), half stay a tight race, and the test asserts both counters are non-zero, so a one-sided schedule fails loudly instead of passing quietly. Mutation proof, and the reason this mattered: changing Fixed — tests
Fixed — documentation that described behaviour the code does not have
Declined
Verification
|
…ce window Round 2 broke windows-latest / Java 21 (612 tests, 2 failures, with ubuntu and macOS green). Both are fixed here without waiving the platform: - The exit-hook test had moved the decision of when the forked harness exits from a sleep inside it to harness.destroy() in the test. destroy() is a SIGTERM only on POSIX; on Windows it is TerminateProcess, which runs no shutdown hooks, so the worker legitimately outlived the harness. The harness now polls a sentinel file and exits itself with System.exit(0) once the test has observed the worker: the window stays test-controlled and the hooks run on every platform. - The provision-race test widens its race window with a --ignore-term worker, which cannot wedge anything on Windows, so close() finishes in milliseconds and the racing loop can miss the terminated guard. The guard assertion now runs only where destroy() is a signal; the platform-independent half - no lingering child processes - still runs everywhere. That code is unchanged from the previous Windows-green commit, so this was a timing flake rather than a new defect. Also close a real orphan window in the release path. stop() removed the worker from owned and then escalated to destroyForcibly on a daemon thread, so for the whole five-second grace the process was tracked by neither set. close() was covered, because shutting the executor down interrupts the escalation and its handler destroys forcibly, but a bare JVM exit was not: the daemon dies with the JVM and the hook's snapshot could not see the worker. A worker that ignores SIGTERM, released and then hit by a Broker SIGTERM inside five seconds, survived. The worker now moves from owned to starting under the same lifecycle lock terminateAll() snapshots with, which also removes the microsecond gap between those two set operations, and leaves it when the escalation finishes. A forked test releases a wedged worker and exits the harness JVM inside the grace window; without the tracking it fails with that worker still alive. Smaller corrections from the same audit round: the cross-process stress rises to 600 rounds so its raced arm keeps the 200 rounds the odd/even split had halved, and its comments now say what the win counters prove - both arms ran - instead of claiming a contradictory interleaving was reached; the v3 backoff test sweeps attempts 0-1000, pinning the inner shift clamp (an unclamped shift wraps at attempt 57 and schedules zero and negative delays, returning the polling to the rate the backoff exists to remove); and the design doc's description of the pre-fix LOST reclaim is corrected in both languages, since at the merge base it ran a single bounded pass per phase and stranded larger generations rather than looping without a budget. A test name and two assertion messages that still carried rationales round 2 retracted in production are corrected, and the exit-hook test's worker cleanup moves into its finally block so a failed assertion cannot leave a spinning node process on the runner. Declined: a production seam inside the release transaction to make the row-lock pin deterministic. The raced arm already detects the missing FOR UPDATE in three runs out of three, with three to five contradictions each, and the transition's in-transaction re-check is pinned deterministically by the delegating-repository release test. Suites green: runtime-broker 631 tests / 0 failures / 2 skipped with checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19 against the reinstalled jar.
Self-audit round 3 (adversarial + undirected) — dispositionsTwo passes over 20d8f48: the adversarial one reported 1 Critical + 3 Suggestions, the undirected one 1 Critical + 7. The undirected Critical is real and CI confirms it — round 2 broke the Windows leg. The adversarial Critical's main claim does not survive measurement, but two of its sub-points are correct. Everything below is fixed in 56c2096 except three declined items. Round 2 broke
|
Round 4 found no Critical: an adversarial pass over the tests and an undirected pass over the whole diff returned eleven Suggestions between them, all addressed here. Two of the newest tests described a mechanism that is not what happens. The release-grace harness claimed its exit kills the daemon escalation thread, but System.exit runs the shutdown hooks first, so during the hook's own grace wait that thread is alive and destroys the worker at about the same moment; what the hook's snapshot actually provides is keeping the JVM alive until the reclaim completes and issuing the destroy itself. The test-side comment claimed its poll loop had to outlast that grace window, when the loop only starts after the harness has exited and halted, so what it waits out is pid reaping. The guard assertion that only makes sense where destroy() is a signal became its own test guarded by an assumption, so a platform that cannot reach the guard reports skipped instead of passing without exercising it. The shared race fixture moved into a helper, and the platform-independent half - no child process outlives the provisioner - still runs everywhere. Both forked tests cleaned up by killing a bare pid, which a recycled pid would turn into killing an unrelated process. They now require that pid to still name a node worker, and the liveness poll applies the same identity check so a recycled pid cannot read as a false failure. The clock the cooldown tests advance is volatile now, since the service reads it from the HTTP handler and coordination threads while the test thread writes it. The provisioner's close() also clears the tracking set, whose entry a discarded escalation task would otherwise never remove. The synchronized block in stop() cannot be pinned from outside the lock, so its comment now says the guarantee is structural, the way the spawn-and-register javadoc already does. The tracking set's field comment names both of its membership windows instead of only the pre-issuance one. The backoff sweep no longer ties its range to the default window, which has a floor but no ceiling. Documentation: the design docs still described a 200-round stress when the code runs 600, and understated which outcomes the release primitive's contract test covers; the deployment README's guidance for opting into a non-loopback bind was weaker than the server's own refusal message, which asks for a TLS-terminating layer that authorizes callers rather than a restricted network, on a face that serves one global bearer token in plaintext; and the release transaction gains a comment recording that its statement order is load-bearing under InnoDB repeatable read, where a read moved ahead of the locking read would silently reopen the race without H2 showing it. Suites green: runtime-broker 632 tests / 0 failures / 2 skipped with checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19 against the reinstalled jar.
Self-audit round 4 (adversarial + undirected) — dispositionsTwo passes over 56c2096, including a line-by-line review of the only production change in that commit ( Both passes cleared the new production change
Fixed — tests
Fixed — production, small
Fixed — documentation
Also noted, no change
Verification
|
…ntry Moving the no-active-execution check into beginSessionRelease left one path without any check at all. A Session row already persisted as RELEASING short-circuits transitionSessionToReleasing before the transaction runs, so release() went straight to the worker and marked the row RELEASED over a live execution, answering 200 released=true where the previous code answered 409 runtime_session_busy. This build cannot create such a row: every writer of RELEASING checks active executions, admission needs READY under the same row lock, and RELEASING never returns to READY. The rows that do exist come from before the guard - a pre-fix peer's check-then-act admitted an execution and its worker release then failed, or a mixed-version rolling deploy has an old peer committing the transition while a new peer admits. That is the deployment model this change's own design doc names, and the residue is the state the original bug produced, so the release path is exactly where it has to be tolerated. Completing such a release tells a worker to stop serving a session while a tool is still running on it, and it pins the binding as well, since the drain then refuses workspace close with workspace_close_execution_unsettled. The re-entry now asks the execution repository before treating the retry as idempotent, throwing the same 409 code and message the guarded transition throws. Admission still needs READY under the row lock, so nothing new can become active after this check: it closes a residue path, not a second race window. A row with nothing active still releases, so the idempotent retry is kept. releaseRetryOverAPersistedReleasingRowRefusesALiveExecution seeds exactly that row - RELEASING with a PREPARED execution on the same binding and generation - and asserts the busy 409, that the row stays RELEASING, and that the worker was never told to release; it then settles the execution and asserts the same retry completes and the row reaches RELEASED. Removing the check fails it. The design doc is corrected in both languages where this change made it wrong: the database round trip is not gone on the re-entry path, and the tracking set now covers the post-release grace window as well as the ready handshake. It also records that the observation cooldown cannot help the shipped MCP poller, which asks with reconcile=true and bypasses the cache by design, so the fan-out measured on that path is unchanged. Suites green: runtime-broker 633 tests / 0 failures / 2 skipped with checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19 against the reinstalled jar.
start() builds its closed-provisioner refusal as retryable, but the managed-context catch below it retypes every RuntimeException into a non-retryable 503 "Managed context startup failed; recovery is blocked." A turn that merely raced a rolling restart therefore failed permanently instead of being retried, and the message told an operator that recovery was blocked when the provisioner had simply been closed. The refusal is now rethrown unchanged, identified by instance rather than by message, and a race test drives a managed-context provision into the guard and asserts both the message and retryable=true; removing the rethrow fails it. The embedded broker's listener failure now names its actual cause. The same catch covers the token and port validation, so an over-long token used to be reported as "Runtime Broker listener could not start" with the real reason only in the cause chain. The release contract gains the leg its busy assertion could not distinguish: every fixture owned exactly one session on its own binding, so the runtime_session_busy leg passed identically whether the predicate is session-scoped or binding-scoped. A sibling session on the same binding now releases while the busy one stays refused, on both backends. Widening that predicate by one token would otherwise make every healthy session on a busy binding unreleasable, with a non-retryable 409 telling the client not to try again. The contract's javadoc also stops claiming it verifies atomicity: every leg is single-threaded, and the atomicity evidence is the cross-process race in Issue13183AdversarialTest. The deployment README records that raising the v3 result window above 30m buys nothing on the shipped path, because the TypeScript client stops observing a v3 execution at its own fixed 30-minute deadline. Suites green: runtime-broker 634 tests / 0 failures / 2 skipped with checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19 against the reinstalled jar.
Review-thread dispositions — all 18 open threads answeredEvery open thread now has a reply with its disposition and, where a fix landed, the commit and the mutation proof. Threads are left unresolved so the maintainer can close them in one pass. Fixed — Critical
Fixed — production
Fixed — tests
Declined, with evidence in each thread
Deferred to #13275 under this repo's ~5-review-round rule (three bot rounds plus five self-audit rounds; the last waves land Critical fixes only)
Verification at |
The cooldown stamp ran for any throwable, so an Error escaping the lookup was cached as a synthetic UNRESOLVED answer. The first caller still saw the failure, but every cooled observer arriving within the next second got a successful stage, and the HTTP face answered 409 runtime_broker_execution_unknown with retryable=false and no trace of the internal failure. Both neighbouring paths already refuse that downgrade — the takeover handle checks that the unwrapped error is not an Error, and the HTTP observation rethrows one — so the cache was the single place that turned an Error into a normal answer. The stamp now skips Errors. A lookup that failed with an ordinary exception still cools as before, which is what failedUnknownLookupCoolsDownLikeACompletedOne pins: the Runtime was just unreachable and hammering it changes nothing, whereas an Error means the process itself is broken and the next observation should try. anErrorDoesNotArmTheObservationCooldown throws from the transport's status call, asserts the observation fails, then asserts that the next observation inside the same cooldown window reaches the worker again. Removing the Error filter fails it (expected 2 worker status calls, got 1) while the failed-lookup cooldown test stays green, so the filter excludes Errors and nothing else. Suites green: runtime-broker 635 tests / 0 failures / 2 skipped with checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19 against the reinstalled jar.
Round 5 found no Critical and three Suggestions, all about claims and oracles rather than behaviour. The managed-context refusal fix from the previous commit was described as keeping a rolling restart from permanently failing the turn that raced it. It does not: RuntimeBrokerService blocks recovery for any managed-context provision failure, retryable or not. What the fix actually preserves is the refusal's own message and retryable flag for callers that provision directly, and it stops reporting "recovery is blocked" when the provisioner was merely closed. The production comment and the test's javadoc now say that instead, so the next reader does not re-derive the wrong invariant. Both forked tests judged liveness with ProcessHandle.isAlive(), which counts a Linux zombie as alive. The module already owns the fix for that in ProcessTrees.running(); combined with the identity filter's unreadable-command fallback, an unreaped zombie on a container runner would have read as a live worker for the whole 20 s poll and failed a test whose worker was dead. Both sites now use running(). The backoff's tail is pinned by equality rather than by bounds. The bounds version could not see an in-bounds schedule that dipped back to 100 ms on every other attempt, which restores the 10/s polling the backoff exists to remove; the equality version fails it at attempt 9 and still fails an unclamped shift at attempt 57. The wide range is about the tail sitting exactly at the cap, not about the window having no ceiling. The race helper closes its provisioner in a finally, so a throw mid-helper cannot leave a wedged worker alive for the rest of the surefire JVM, and asserts that the closer thread finished, so a wedged close() names itself instead of surfacing as the lingering-children failure. Suites green: runtime-broker 635 tests / 0 failures / 2 skipped with checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19 against the reinstalled jar.
Self-audit round 5 (undirected + adversarial) — dispositions and convergenceTwo passes over 4e91dc5. The undirected one independently re-derived the The Critical both passes converged onThe undirected pass traced the same regression from the other direction: at 4e91dc5 That makes three independent sources converging on one defect (bot review, the verification rig, this pass) and no other Critical in five rounds. Fixed
ConvergenceRound 5 is the last full round under the stated rule (Suggestions in scope through round 5, Criticals only afterwards). Its yield: one Critical that three sources converged on and that is fixed, plus five Suggestions of which four were claim/oracle corrections and one a convention fix — no new production race, deadlock, security or data-loss defect. Both passes reported the areas they attacked and found solid, including the four blind spots round 5 was aimed at:
Remaining Suggestions are recorded in #13275: three test oracles that cannot currently fail for the property they are named after, and the Verification
|
The red
|
|
Re-ran: Worth recording, because the first attempt hid more than one check: it failed at step 9, which skipped steps 10-15 — including step 12 So the concurrency and fault-injection gates most relevant to this PR — the storage fault gates against the release transition, and the in-flight and continuation owner-failover E2Es — have now run green on the final head, alongside the Java matrix (ubuntu 11/17/21, macOS 21, Windows 21), MariaDB, Real daemon E2E and the Node lanes. Check tally: 21 pass / 0 fail / 8 skipped, with only the |
…es not give The bot's round-4 review found a Critical in this branch's own hardening record rather than in its code. The design doc claimed a parked renewal tick "no longer delays coordination work", and managed-runtime-broker-service-core claimed a stalled storage call "cannot queue coordination work". Neither holds: a tick parked inside its JDBC call still holds its claim's renewal monitor, and three of the paths that take that monitor run as tasks on the single-thread coordination scheduler — the provisioning fence's stopAndGet(), the reclaim fence's close(), and reconcileLoop's close() — so a stall long enough to park a tick can still block that thread, and with it every other execution's result polls, fences, retries and cooldown evictions. No renewal JDBC call sets a statement or socket timeout. The behaviour is not a regression. At the merge base the parked tick was the coordination thread, so the split strictly narrowed the harm; the defect was the certification, which is the one thing a hardening record exists to state accurately. The next responder to a frozen broker would have ruled this class out on the strength of that sentence and looked elsewhere while the coordination thread sat blocked in the frame the original incident produced. Both documents, in both languages, now say what the split buys and what it does not, and name the two changes that would close the rest — a statement/socket timeout on renewal JDBC, or a monitor-free renewal handle — as tracked in #13275 rather than done here. The same review found the 2 s poll cap described as a bound on how late a finished result is picked up: in the design doc, in both languages, and in the v3PollDelayMillis javadoc this branch added. A single-thread executor guarantees a task starts no earlier than its delay and never guarantees it starts within it, and every execution's rounds share that thread, so pick-up lateness under concurrency is the cap plus the thread's queue wait. All three now say that. Two older documents are corrected alongside, and one log line added. The 2026-09-23 process-adoption page still promised that stop and close never escalate past SIGTERM, which this branch's bounded-force shutdown falsifies for non-durable workers; it now states the 5 s grace and the three places it runs, and cross-links the hardening page in both languages. Its neighbouring sentence — a Broker crash can still orphan the worker — stays, because a SIGKILLed broker runs no hook. And the embedded broker logs the resolved v3 result window at startup, so a suffix-less "30" that bound as 30 milliseconds is visible in the log instead of only in degraded tool calls. Suites green: runtime-broker 635 tests / 0 failures / 2 skipped with checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19 against the reinstalled jar.
Bot review round 4 (R4-1 … R4-3) plus two round-1 threads that a pagination window had hidden — all answeredFixed in b81f21d. The Critical was in this branch's own hardening record rather than in its code. R4-1 (Critical) — the design doc certified a renewal isolation the code does not provide. Verified against the code, not just the report: R4-2 — the 2 s poll cap was described as a bound on pick-up lateness in the design doc (both languages) and in the R4-3 — the 2026-09-23 process-adoption page still promised R1-2 and R1-3 were never answered: my earlier thread sweep used a Verification: runtime-broker 635 tests / 0 failures / 2 skipped with checkstyle and spotbugs clean; managed-agent-server fix-adjacent 19/19. (The first run of the consumer suite failed to compile against a stale |
The commit that narrowed the design docs left the code comment certifying the same absolute guarantee: newRenewalScheduler() still said one stalled storage call "cannot queue retries, polls, and every other binding's renewal past its lease". That is the sentence the docs no longer say, and it sits at the definition a maintainer changing renewal threading reads first, some 3700 lines away from the narrowed paragraph. It now states what the split buys — a stalled tick no longer occupies the coordination thread, and a second thread absorbs one slow call — and what it does not: the tick holds its claim's monitor while parked, and the fence and settlement paths take that monitor from coordination tasks, so a long stall can still block coordination. #13275 carries the two changes that would close the rest. Also bump the freshness stamp on the 2026-09-23 process-adoption page in both languages. The previous commit rewrote its SIGTERM/SIGKILL sentence to record the bounded-force escalation but left `Updated: 2026-09-24`, which made the page claim to predate the 2026-10-02 hardening cited eight words later in the same paragraph; the date line sits outside the diff hunk, so the contradiction was invisible in review. The other four design docs this branch edits carry no Updated field, so no other stamp is stale. Comment and documentation only, no behaviour change. Verified with test-compile, checkstyle:check and spotbugs:check.
…threads The dispatch-renewal test's javadoc said a stalled binding renewal cannot starve dispatch renewals. With a two-thread pool that holds for one stall and for two, and stops there, so the comment now says what the pool size actually buys instead of an absolute guarantee - the same overclaim class the design docs and newRenewalScheduler()'s comment were just corrected for. Comment only.
Real-stack verification, round 2 — head
|
| scenario | base | round 1 | round 2 |
|---|---|---|---|
| Two-JVM release vs admission, 300 rounds (contradictions) | 70/300 | 0/300 | 0/300 |
| same, +5 ms per DB hop, 200 rounds | 89/200 | 0/200 | 0/200 |
R3-1: release over a seeded RELEASING + PREPARED row |
409 busy | 200 released=true ❌ |
409 runtime_session_busy, row stays RELEASING, worker not told |
idle RELEASING row (idempotent retry) |
— | 200 | 200 |
| renewal parked in a real InnoDB lock wait: healthy execution on another binding | UNKNOWN | SETTLED | SETTLED / success |
| two parked renewals (pool bound, now stated in the design doc) | — | UNKNOWN | UNKNOWN |
| Spring SIGTERM with the worker frozen | survives | killed +5.1 s | killed +5.1 s |
| library JVM SIGTERM: healthy / handshake worker | orphaned / outlives | +0.1 s / +0.1 s | +0.1 s / +0.1 s |
| LOST reclaim, 303 sessions (simulated power loss) | 503 → 200 | 200 | 200 |
shipped MCP poller ?reconcile=true, 20 s (GETs / worker status calls) |
60 / 27 | 64 / 29 | 56 / 24 — unchanged by design, now documented and tracked in #13275 |
Spring startup matrix (14 cases × 3 arms): same posture as round 1. The startup log now prints the resolved window (listening at … (v3 result window PT30M)). PORT=70000 fails with Runtime Broker listener could not start: port out of range:70000; round 1 named no cause (R3-2).
Test suites:
- runtime-broker: 635 tests, 0 failures (2 skipped); SpotBugs 0; Checkstyle 0.
- MySQL IT profile: 7/7 on a fresh database.
- managed-agent-server fix-adjacent: 19/19.
New fix verified: the release grace window (56c209634b)
Setup:
- The worker: the real bundled worker, launched with a preload that drops every SIGTERM listener, modelling a wedged graceful shutdown.
- The release: the proxy drops the service's attestation reply, so provisioning fails and
releaseQuietly→provisioner.stop()runs.
| released worker ignores SIGTERM | base | round 1 | round 2 |
|---|---|---|---|
| Broker keeps running | still running at +9.5 s | killed +4.98 s | killed +4.97 s |
| Broker JVM gets SIGTERM inside the 5 s grace window | orphaned (ppid 1) | orphaned (ppid 1) | killed +4.96 s |
G1 — the load-bearing InnoDB statement order is pinned by no test (suggestion)
The new comment in JdbcRuntimeBindingRepository.beginSessionRelease is right: under REPEATABLE READ the execution read must be the transaction's first consistent read, taken after the FOR UPDATE. I checked it with mutant M1, which adds one consistent read (hasActiveByRuntimeSession, result ignored) ahead of the locking read (mutant-m1.patch).
| oracle | head | M1 |
|---|---|---|
| two-JVM race on real MySQL, 300 rounds | 0 contradictions | 43 contradictions |
| runtime-broker unit suite (H2), including the 200-round stress | 635 / 0 failures | 635 / 0 failures — survives |
existing mysql-integration IT profile |
7/7 | 7/7 — survives |
| candidate IT, MySQL 8.4.7 | 10/10 green | 3/3 red |
candidate IT, MariaDB 10.11.18 (the CI mysql-integration image) |
5/5 green | 3/3 red |
Candidate: candidate-innodb-order-it.patch, JdbcRuntimeBrokerMySqlIT.releaseTransitionSeesAnAdmissionThatCommittedWhileItWaited, 68 lines, Checkstyle clean. How it works:
- A holder connection takes the Session row
FOR UPDATE. fixture.prepare(...)queues on that lock first, andbeginSessionReleasequeues behind it. Both waits are confirmed ininformation_schema.innodb_trx, scoped to the IT's schema.- The holder commits.
- Expected: the admission is
PREPARED, the release gets 409runtime_session_busy, and the Session staysREADY. M1 fails withExpected ExecutionException … nothing was thrown: the release went toRELEASINGover the admitted execution.
It needs nothing new in CI; it runs in the existing MariaDB mysql-integration job.
Notes
- N1 (pre-existing, unrelated):
JdbcRuntimeBrokerMySqlIT.managedRecoveryKeepsPinsUntilTheOriginalHolderCleanupCompletesfails withRuntime credential key is unavailablewhen the profile is re-run against a database it already ran on. Base fails the same way on its second run. The CI job uses a fresh service container, so this does not affect CI. - Not re-exercised on the real stack this round, so pinned only by the PR's unit tests: R3-5 (the closed-provisioner refusal stays retryable) and the
Error-not-cached change inlookupOnce.
Evidence: assets-pr13214@31717330. It holds the round-2 harness additions (grace.mjs, the ignore-term preload, the extended Spring matrix, the IT runners) and the raw results and logs. Round-1 material stays under pr13214/.
中文版
真实环境验证第二轮 — head 5cd1c832f4
结论:从真实环境验证的角度看,可以合并。 第一轮的 F1(bot R3-1)已用与我当时提议完全相同的检查修复,并端到端验证通过。新增的宽限期修复在真实 worker 上同样成立。第一轮的全部场景都在新 head 上重跑了一遍,没有回归。
新增一条不阻塞的建议(G1):释放修复所依赖的 InnoDB 语句顺序,没有任何在 CI 里运行的测试钉住。破坏这一顺序的变异体在 MySQL 上会重新打开竞态,却能通过全部 635 个单测和现有的 MySQL IT。我附上了一个能杀死它的 68 行 IT。
验证期间 head 未变;PR 在 af6691ce1f 之上新增了 10 个提交,没有合并 main。与今天的 main(2c591ecc08,在 sdk-java 范围内只改了 managed-agent-server/README.md)试合并干净。CI 21 项通过、0 失败,review-pr 仍在运行。reviewDecision 仍显示 bot 较早的那次 CHANGES_REQUESTED。
同一装置重跑
装置:真实 MySQL 8.4.7;独立的 Broker JVM;打包 worker;真实的 managed-agent-server jar。三臂为 base 5130c1a734、第一轮 af6691ce1f、第二轮 5cd1c832f4,各自都按字节码确认了身份。
| 场景 | base | 第一轮 | 第二轮 |
|---|---|---|---|
| 两个 JVM 释放与准入竞态,300 轮(矛盾态次数) | 70/300 | 0/300 | 0/300 |
| 同上,每跳 +5 ms,200 轮 | 89/200 | 0/200 | 0/200 |
R3-1:对预置的 RELEASING + PREPARED 行发起释放 |
409 busy | 200 released=true ❌ |
409 runtime_session_busy,行保持 RELEASING,worker 未收到释放 |
空闲的 RELEASING 行(幂等重试) |
— | 200 | 200 |
| 续约卡在真实 InnoDB 锁等待中:另一 binding 上的健康 execution | UNKNOWN | SETTLED | SETTLED / success |
| 两个续约同时卡住(线程池上限,设计文档已写明) | — | UNKNOWN | UNKNOWN |
| Spring 收到 SIGTERM,worker 已被冻结 | 存活 | +5.1 s 被杀 | +5.1 s 被杀 |
| 嵌入式库 JVM 收到 SIGTERM:健康 worker / 握手中的 worker | 孤儿 / 存活更久 | +0.1 s / +0.1 s | +0.1 s / +0.1 s |
| LOST 回收,303 个 session(模拟断电) | 503 → 200 | 200 | 200 |
随产品发布的 MCP 轮询 ?reconcile=true,20 s(GET 次数 / worker status 调用) |
60 / 27 | 64 / 29 | 56 / 24(按设计不变,已写入文档并在 #13275 跟进) |
Spring 启动矩阵(14 个用例 × 3 臂)的监听行为与第一轮一致。启动日志现在会打印解析后的窗口(listening at … (v3 result window PT30M))。PORT=70000 现在报 Runtime Broker listener could not start: port out of range:70000,第一轮不说明原因(R3-2)。
测试套件:
- runtime-broker: 635 个测试 0 失败(2 个跳过);SpotBugs 0;Checkstyle 0。
- MySQL IT profile: 新库上 7/7。
- managed-agent-server 修复相关测试: 19/19。
新修复验证:释放宽限期(56c209634b)
装置:
- worker: 真实打包 worker,以丢弃所有 SIGTERM 监听器的 preload 启动,模拟卡死的优雅关停。
- 释放: 代理丢弃 service 的 attestation 应答,使 provisioning 失败,触发
releaseQuietly→provisioner.stop()。
| 被释放的 worker 忽略 SIGTERM | base | 第一轮 | 第二轮 |
|---|---|---|---|
| Broker 继续运行 | +9.5 s 时仍在运行 | +4.98 s 被杀 | +4.97 s 被杀 |
| Broker JVM 在 5 s 宽限期内收到 SIGTERM | 成为孤儿(ppid 1) | 成为孤儿(ppid 1) | +4.96 s 被杀 |
G1 — 承重的 InnoDB 语句顺序没有测试钉住(建议)
JdbcRuntimeBindingRepository.beginSessionRelease 里新增的注释是对的:在 REPEATABLE READ 下,execution 读取必须是事务的第一次一致性读,并且发生在 FOR UPDATE 之后。我用变异体 M1 做了验证:在加锁读之前加一次一致性读(hasActiveByRuntimeSession,忽略结果)(mutant-m1.patch)。
| 判据 | head | M1 |
|---|---|---|
| 真实 MySQL 上的两 JVM 竞态,300 轮 | 0 次矛盾 | 43 次矛盾 |
| runtime-broker 单测(H2),含 200 轮压力测试 | 635 / 0 失败 | 635 / 0 失败 —— 存活 |
现有 mysql-integration IT profile |
7/7 | 7/7 —— 存活 |
| 候选 IT,MySQL 8.4.7 | 10/10 通过 | 3/3 失败 |
候选 IT,MariaDB 10.11.18(CI mysql-integration 用的镜像) |
5/5 通过 | 3/3 失败 |
候选补丁:candidate-innodb-order-it.patch,即 JdbcRuntimeBrokerMySqlIT.releaseTransitionSeesAnAdmissionThatCommittedWhileItWaited,68 行,Checkstyle 干净。做法:
- 一个 holder 连接对 Session 行加
FOR UPDATE锁。 fixture.prepare(...)先在这把锁上排队,beginSessionRelease排在它后面。两个等待都在information_schema.innodb_trx中按本 IT 的 schema 确认。- holder 提交。
- 期望:准入结果为
PREPARED,释放得到 409runtime_session_busy,Session 仍是READY。M1 下失败为Expected ExecutionException … nothing was thrown:释放越过已准入的 execution 进入了RELEASING。
CI 无需任何新增,它会在现有的 MariaDB mysql-integration 任务中运行。
备注
- N1(既有问题,与本 PR 无关): 对同一个库重跑 profile 时,
JdbcRuntimeBrokerMySqlIT.managedRecoveryKeepsPinsUntilTheOriginalHolderCleanupCompletes会报Runtime credential key is unavailable。base 第二次运行同样失败。CI 每次使用全新的服务容器,因此不受影响。 - 本轮未在真实环境中重新演练、只由 PR 单测钉住的两项:R3-5(关闭后的 provisioner 拒绝仍可重试)、
lookupOnce不缓存Error。
证据:assets-pr13214@31717330,包含第二轮新增的装置(grace.mjs、ignore-term preload、扩展后的 Spring 矩阵、IT 运行脚本)以及原始结果和日志。第一轮材料仍在 pr13214/ 下。
|
@qwen-code /review |
|
Qwen Code review request accepted. Review is queued for an available runner; follow the workflow run for progress. A command-triggered review is not listed under the checks of this PR; the result is posted here as a review when it finishes. |
Real-stack verification, round 3 — bot round-6 findings re-checked (head still
|
| oracle | head | M1 — an extra consistent read added before FOR UPDATE (round 2) |
M2 — the execution read hoisted before FOR UPDATE (bot D6-8) |
|---|---|---|---|
| two-JVM race, real MySQL 8.4.7 (REPEATABLE READ), 300 rounds | 0 contradictions | 43 | 7 |
PR's H2 stress concurrentCrossProcessAdmitAndReleaseNeverContradict, 30 runs each, host load 100–150 |
0/30 red | 0/30 red — invisible to H2 | 30/30 red — H2 catches it |
| candidate InnoDB IT, MySQL 8.4.7 | 10/10 green | 3/3 red | 3/3 red |
| candidate InnoDB IT, MariaDB 10.11.18 (round 2) | 5/5 green | 3/3 red | not run |
The H2 data source in the test's URL shape runs at READ_COMMITTED (I measured: H2 2.4.240 isolation=2; nothing in runtime-broker overrides it). At that isolation every statement sees the latest commit:
- M2 (hoisted read): the read happens before the lock, so it misses an admission committed in between. It loses the race on H2 too, and the PR's stress test catches it. D6-8 is right.
- M1 (added read): this only matters under InnoDB REPEATABLE READ, where the first consistent read freezes the snapshot. On H2 the later read still sees the admission, so all 30 stress runs stay green. G1 is right.
So the comment "H2 would not show it" is wrong for a hoist and right for an added read. The rule it states is correct.
Suggested fix (one change addresses both findings): word the comment as "H2 runs READ COMMITTED: it catches a hoisted read, but not an extra read ahead of the lock, which only breaks under InnoDB REPEATABLE READ", and add the round-2 candidate IT, which kills both mutants deterministically. Patches:
The other round-6 probe findings
- R1-2 / D6-4 — confirmed on the real Spring server.
QWEN_MANAGED_AGENT_RUNTIME_BROKER_V3_RESULT_WINDOW=1800, meant as 30 minutes, starts and logs(v3 result window PT1.8S);3600→PT3.6S;500is still refused. The same holds on the trial merge withmain. The new startup log line makes the mistake visible, but nothing stops it. The bot's@DurationUnitsuggestion would close it at the binding. - D6-7 — confirmed by rounds 1 and 2. With one renewal parked in a real InnoDB lock wait, the healthy execution on another binding settles. With two parked, it ends UNKNOWN. The pool absorbs one stall, so the javadoc's "two simultaneous stalls" is off by one.
- D6-3 (the dispatch settlement blocks the coordination thread) and D6-6 (the managed-context drain exit is untested) — not reproduced here. They need a v3 publication or a managed-context placement, which this rig doesn't drive. They rest on the bot's unit-level probes.
Against today's main (6136786c0c)
main moved by 7 commits that touch sdk-java: the Druid pool swap (#13363), a V35 migration, and harness changes. Three of the files they change are also changed by this PR: ManagedAgentProperties, application.yml and EmbeddedRuntimeBrokerTest. Results on the merged tree:
- The trial merge is textually clean.
- qwencode, runtime-broker and managed-agent-server all build. The fix-adjacent
EmbeddedRuntimeBrokerTest+ManagedAgentPropertiesTestpass 19/19. - On the merged Druid jar, the Spring startup subset behaves exactly as on head: non-loopback refusal,
ALLOW_NON_LOOPBACKopt-in, the 1 s floor,PT1.8Sfor1800, and theport out of rangemessage.
Evidence: assets-pr13214@b06d858c. It contains the StressRepeat driver (runs the PR's stress test N times per jar), the H2 isolation probe, the M2 patch, the merge build script and every run log. Rounds 1 and 2 stay under pr13214/ and pr13214/r2/.
中文版
真实环境验证第三轮 — 复核 bot 第 6 轮发现(head 仍为 5cd1c832f4)
结论:与第二轮相同——从真实环境验证的角度看,可以合并。 第二轮之后 head 没有变化,因此本轮做两件事:在真实环境中复核 bot 第 6 轮记录的 4 条 [probe] 发现(无 Critical,均为延后),并对照今天的 main 复验本 PR。
其中 D6-8 看起来与我第二轮的 G1 矛盾。实测表明两者针对的是不同的变异体,各自都对,而且建议的修法相同。
CI:23 项通过,0 项失败。
D6-8 与第二轮 G1:H2 能抓到哪一种读顺序重排?
| 判据 | head | M1:在 FOR UPDATE 之前新增一次一致性读(第二轮) |
M2:把 execution 读取提前到 FOR UPDATE 之前(bot D6-8) |
|---|---|---|---|
| 两个 JVM 竞态,真实 MySQL 8.4.7(REPEATABLE READ),300 轮 | 0 次矛盾 | 43 | 7 |
PR 自带的 H2 压测 concurrentCrossProcessAdmitAndReleaseNeverContradict,每臂 30 次,宿主负载 100–150 |
0/30 失败 | 0/30 失败——H2 看不出来 | 30/30 失败——H2 能抓到 |
| 候选 InnoDB IT,MySQL 8.4.7 | 10/10 通过 | 3/3 失败 | 3/3 失败 |
| 候选 InnoDB IT,MariaDB 10.11.18(第二轮) | 5/5 通过 | 3/3 失败 | 未跑 |
按测试所用 URL 形态建立的 H2 数据源运行在 READ_COMMITTED 隔离级别(我实测为 H2 2.4.240 isolation=2,runtime-broker 中没有任何地方覆盖它)。在这一级别下,每条语句都能看到最新提交:
- M2(提前读取): 读取发生在加锁之前,会漏掉在此期间提交的准入。它在 H2 上同样会输掉竞态,PR 的压测能抓到。D6-8 成立。
- M1(新增读取): 只有在 InnoDB REPEATABLE READ 下才有影响,因为首次一致性读会冻结快照。在 H2 上,加锁之后的那次读取仍能看到准入,所以 30 次压测全部通过。G1 成立。
因此注释“H2 would not show it”对“提前读取”不成立,对“新增读取”成立;它陈述的规则本身是对的。
建议修法(一处改动同时解决两条发现):把注释改为“H2 运行在 READ COMMITTED:它能抓到提前读取,但抓不到在锁之前新增的读取,后者只在 InnoDB REPEATABLE READ 下出错”,并加入第二轮的候选 IT,它能确定性地杀死两个变异体。补丁:
第 6 轮的其他 probe 发现
- R1-2 / D6-4——在真实 Spring 上成立。
QWEN_MANAGED_AGENT_RUNTIME_BROKER_V3_RESULT_WINDOW=1800(本意是 30 分钟)能正常启动,日志显示(v3 result window PT1.8S);3600→PT3.6S;500仍被拒绝。与main试合并后结果相同。新增的启动日志让这个错误能被看到,但无法阻止它。bot 建议的@DurationUnit能在绑定环节根治。 - D6-7——第一、二轮的实测已证实。 一个续约卡在真实 InnoDB 锁等待中时,另一 binding 上的健康 execution 能正常完成;两个同时卡住时,它变为 UNKNOWN。线程池只能吸收一个卡住的续约,javadoc 中“两个同时卡住”差了一。
- D6-3(分发结算阻塞协调线程)、D6-6(managed-context 清空循环的退出分支未被测试)——本装置未复现。 二者需要 v3 publication 或 managed-context placement,本装置不驱动这两者,目前只有 bot 的单元级 probe 支撑。
对照今天的 main(6136786c0c)
main 新增了 7 个涉及 sdk-java 的提交:Druid 连接池替换(#13363)、V35 迁移和若干 harness 改动。其中有三个文件本 PR 也改了:ManagedAgentProperties、application.yml、EmbeddedRuntimeBrokerTest。合并后的结果:
- 试合并没有文本冲突。
- qwencode、runtime-broker、managed-agent-server 均能构建;修复相关的
EmbeddedRuntimeBrokerTest+ManagedAgentPropertiesTest19/19 通过。 - 在合并后的 Druid jar 上,Spring 启动子集与 head 表现完全一致:拒绝非回环地址、
ALLOW_NON_LOOPBACK显式开启、1 秒下限、1800解析为PT1.8S、port out of range提示。
证据:assets-pr13214@b06d858c,包含 StressRepeat 驱动(对每个 jar 重复运行 PR 的压测 N 次)、H2 隔离级别探针、M2 补丁、合并构建脚本和全部运行日志。第一、二轮的材料仍在 pr13214/ 和 pr13214/r2/ 下。
|
@qwen-code /triage |
qqqys
left a comment
There was a problem hiding this comment.
Critical-only review at head 5cd1c832. No blocking issue found — approving.
The standing CHANGES_REQUESTED on this PR is stale: it was filed in round 4 against head 51a526df, and the two later rounds (at b81f21d2 and this head) were submitted as comments with no Critical. A later clean verdict does not by itself discharge an earlier Critical, so I verified all four historically-filed Criticals in the code at this head rather than reading the round-6 ledger's empty finding list as proof.
R1-1 (cooldown cache-hit pairing) — fixed. lookupOnce:1158-1183 no longer builds a fresh reconciliation out of the caller's snapshot. When the cached record's state is no longer UNKNOWN it returns the cached ExecutionReconciliation whole, with a comment naming the exact defect: pairing a terminal answer with the caller's pre-settlement UNKNOWN snapshot would report a settled execution as unknown. When both records are still UNKNOWN it takes the higher-version record and pairs it with the cached outcome, so a cancel landing between the two snapshots is not lost. The cache is consulted only under cooled and inside the deadline, and the mutation paths (:start, :cancel) pass false, so they never serve it.
R2-1 (POSIX-signal test with no OS guard) — fixed. FakeAttestationWorkerTest.ignoreTermSurvivesSigterm now carries @DisabledOnOs(OS.WINDOWS) at lines 161-162.
R3-1 (already-RELEASING re-entry left unchecked) — fixed, on both paths. JdbcRuntimeBindingRepository.beginSessionRelease performs the no-active-execution check inside the same transaction as the RELEASING transition, takes the Session row FOR UPDATE first, and documents why the statement order is load-bearing under InnoDB REPEATABLE READ — the plain execution read must be the transaction's first consistent read so its read view is built after the lock is held and therefore sees an admission that committed before it, with the note that H2 would not expose a reordering. The idempotent re-entry that beginSessionRelease:97-100 returns early for is covered separately by transitionSessionToReleasing:3452-3468, which re-checks hasActiveByRuntimeSession for an already-RELEASING row and refuses runtime_session_busy rather than completing a release that would free a worker still running a tool. finishSessionRelease needs no check of its own because it is only reached past that gate.
R4-1 (design doc over-claiming renewal isolation) — fixed. The "Renewal pool" decision now states the limit explicitly: a tick parked inside its JDBC call occupies one of the pool's two threads and can delay other renewals, the split "does not make coordination immune to a stall" because the parked tick still holds its claim's renewal monitor which the provisioning fence, reclaim fence and reconcile re-entry all take from coordination-scheduler tasks, and closing that needs a statement/socket timeout or a monitor-free renewal handle, deferred to #13275. That is a precise statement of what the change buys, not an absolute guarantee.
My own scan of the current diff, on the paths where a Critical would plausibly live. The loopback guard in RuntimeBrokerHttpServer is fail-closed in the right places: it refuses when address.getAddress() is null (an unresolved bind) as well as when it is non-loopback, it runs in the constructor before anything binds, and the pre-existing three-argument constructor delegates with allowNonLoopback = false, so every current caller keeps the safe default and the opt-in is purely additive for a face that serves one global Bearer token over plaintext HTTP. MIN_V3_RESULT_WINDOW is a 1 s floor with the constructor refusing a suffix-less value that would bind as milliseconds. The cooldown eviction is scheduled against the same clock the deadline check reads, so a service clock differing from the scheduler's wall time cannot evict an entry its reads still consider fresh, and the eviction's catch drops cooldown state on a closing service rather than leaking it.
Coverage, stated plainly so this approval does not over-claim. I read the release/admission transaction, the re-entry path, the cooldown cache-hit, the loopback guard and the config floor at head. I did not read LocalProcessRuntimeProvisioner's new destroy() → bounded grace → destroyForcibly() escalation, its starting-set tracking or its JVM exit hook, nor the EmbeddedRuntimeBroker / ManagedAgentProperties wiring, nor the ~2400 lines of new tests. CI on this head ran almost nothing — 2 checks successful, 97 skipped, 1 cancelled and no failures — so the Java lanes did not execute here and this verdict rests on reading the code, not on a green suite. Neither gap produced a finding I can evidence, and rounds 5 and 6 of the automated review filed none, which is why I am approving rather than deferring; if a maintainer wants the shutdown-escalation path attested, that is the part to look at.
The remaining open items I saw are Suggestions by their own grading (the renewal-scheduler comment wording, a design-doc sentence, the sub-second config floor) and round 4 deferred 20 more under its convergence posture, so I did not treat any of them as blocking or spend budget on them.
Resolve the RuntimeBrokerService conflict with #13214 (two-thread renewalScheduler) by keeping the ReentrantLock conversion on the new field: both BindingRenewal.start() and DispatchRenewal.start() now lock the ReentrantLock monitor and schedule on renewalScheduler. Tree content byte-identical to the clean rebase result ba919b5591. Co-authored-by: Qwen-Coder <[email protected]>
…#13565) Move the merged-PR history of the Managed Agent dual-path proposal (QwenLM#12380) out of the issue body into a bilingual ledger under docs/design/. The issue body had come within 10 KB of GitHub's 256 KiB limit, so the body will keep only the delivery snapshot and the open PRs, and merged rows move here rewritten to their merged final state. The ledger carries every merged row the issue tracked up to its 2026-10-03 reconcile (adding the missing merge commit to the five earliest rows and normalising the Chinese state cells), rewrites the eight rows the issue still listed as open although their PRs had merged (QwenLM#13141, QwenLM#13166, QwenLM#13174, QwenLM#13210, QwenLM#13214, QwenLM#13217, QwenLM#13218, QwenLM#13247), and adds rows for the 48 managed-agent PRs merged between that reconcile and main 0c13502 that had no row yet. PRs closed without merging (QwenLM#13087, QwenLM#13336) sit in their own table. Later merges land at the next reconcile. Co-authored-by: wenshao <[email protected]>









What this PR does
Fixes the three audit-verified high-risk findings from #13183 plus the two low-cost items of its medium cluster. Release and admission are now decided in one transaction: the RELEASING transition re-checks active executions under the same Session row lock that admission takes, so two Broker processes sharing one database can no longer produce an active execution under a RELEASED session. Lease renewals move off the single coordination thread (which also ran retries, fences, and polls) onto a dedicated two-thread pool, so one stalled JDBC call can no longer fence unrelated healthy bindings; v3 result polling backs off exponentially (100 ms doubling to a 2 s cap) with the polling window now configurable (
v3-result-window, a deadline for the post-dispatch result poll rather than a result-retention period); and automatic UNKNOWN observations reuse the freshest lookup for a one-second cooldown instead of fanning every sequential poll through to the worker — the explicitreconcile=truepath still asks every time. The HTTP face refuses non-loopback listen addresses unless the deployment opts in (allow-non-loopback), since it serves one global Bearer token over plaintext HTTP. Non-durable worker shutdown escalatesdestroy()through a bounded grace window todestroyForcibly(), and a JVM exit hook covers workers too — including ones still in their ready handshake, which are tracked from spawn. A LOST reclaim now loops the bounded 100-row recovery passes under a 16-pass budget instead of answering 503 past 300 sessions, and a generation larger than one budget answersruntime_broker_runtime_lostfor the next reclaim to resume.The remaining medium items are split out as #13202 (credential key ring), #13203 (terminal-row retention), and #13204 (InMemory/JDBC semantic alignment).
Why it's needed
#13183 documents, with reproductions: a cross-process admit/release race that lets a released worker keep running an execution it was told to drop; a single-thread scheduler that turns a 1–2 s storage stall into cross-binding fencing; rapid sequential polling of an UNKNOWN execution fanning every request through to the worker; and a single global token over plaintext HTTP that any peer can use once the host widens past loopback.
Reviewer Test Plan
How to verify
cd packages/sdk-java/runtime-broker && mvn clean test(JDK 21) — 635 tests green, including the newIssue13183RegressionTest(25) andIssue13183AdversarialTest(6). The two classes encode the issue's scenarios with the fixed expectations: the reproduction interleaving of the cross-process race now gets 409runtime_session_busy(and the reverse order getsruntime_admission_closed); a stalled renewal fences only its own binding while an unrelated binding provisions READY; three rapid GETs of an UNKNOWN execution cost one worker status call; a LOST generation with 303 sessions (and a mixed one with 150 executions + 251 sessions) drains in onewarm; a non-loopback bind is refused unless opted in; a SIGTERM-ignoring worker is forcibly destroyed after release, on close(), and on JVM exit mid-handshake (forked-JVM proof).mvn checkstyle:checkis clean. Inpackages/sdk-java/managed-agent-server,mvn test -Dtest='EmbeddedRuntimeBrokerTest,ManagedAgentPropertiesTest'passes (19 tests), covering the newallow-non-loopback/v3-result-windowwiring.Evidence (Before & After)
N/A — non-UI change. Before/after behavior is pinned by the regression tests: the reproduction harness (same interleavings) fails on the base commit and passes on this branch.
Tested on
Environment (optional)
JDK 21 + Maven 3.9.16, H2 in MySQL mode. The module's
mysql-integrationfailsafe profile re-runs the repository layer against a real MySQL; it was not run here.Risk & Scope
allow-non-loopbackis set.allow-non-loopback: falseis the default — deployments binding the broker beyond loopback must opt in explicitly.DurableRuntimeRecoveryTest.lostBranchReclaimsBeyondTheReconcileDeadlineand twoToolPublicationStoreTestcases are timing-sensitive under load.managed-runtime-broker-service-coreandmanaged-runtime-broker-jdbc.Linked Issues
Refs #13183 — the high-risk findings and two medium items are fixed here; the remaining medium items continue as #13202, #13203, #13204.
中文说明
这个 PR 做了什么
修复 #13183 中经审计复现的三个高危发现和 Medium 簇里两个低成本项。释放与准入现在在同一事务内判定:RELEASING 转换在与准入相同的 Session 行锁下复查活跃 execution,共享一个数据库的两个 Broker 进程不再可能出现"execution 活跃而 session 已 RELEASED"的矛盾态。租约续约从单条协调线程(原来还跑重试、围栏和轮询)迁到独立双线程池,一次卡住的 JDBC 调用不再把无关健康 binding 围栏;v3 结果轮询改为指数退避(100ms 倍增、2s 封顶),轮询窗口可配置(
v3-result-window,它是派发后结果轮询的截止期,不是结果保留期);UNKNOWN 执行的自动观测在 1 秒冷却内复用最近一次查询结果,不再把每次顺序轮询穿透到 worker——显式reconcile=true路径仍然每次都问。HTTP 面默认拒绝非回环监听地址,除非部署方显式开启(allow-non-loopback),因为它以明文 HTTP 服务单一全局 Bearer token。非 durable worker 的关停从destroy()经有界宽限期升级到destroyForcibly(),JVM 退出钩子同样覆盖尚在 ready 握手期的 worker(从 spawn 起即被跟踪)。LOST 回收现在在 16 趟预算内循环驱动有界的 100 行恢复批次,而不是超过 300 个 session 就回 503;超出单次预算的代际回答runtime_broker_runtime_lost,由下一次 reclaim 继续。其余 Medium 项拆分为 #13202(凭证 key ring)、#13203(终态行保留作业)、#13204(InMemory/JDBC 语义对齐)。
为什么需要
#13183 带复现地记录了:跨进程 admit/release 竞态让已被通知释放的 worker 继续跑 execution;单线程调度器把 1-2 秒的存储抖动放大成跨 binding 围栏;对 UNKNOWN 执行的高频顺序轮询把每个请求都穿透到 worker;单一全局 token 加明文 HTTP 在监听地址放开回环后可被任何对端利用。
评审者测试计划
如何验证
cd packages/sdk-java/runtime-broker && mvn clean test(JDK 21)——635 个测试全绿,含新增的Issue13183RegressionTest(25 个)与Issue13183AdversarialTest(6 个)。两个测试类以修复后的期望编码了 issue 的场景:跨进程竞态的复现交错现在得到 409runtime_session_busy(反向顺序得到runtime_admission_closed);卡住的续约只围栏自身 binding,无关 binding 正常 READY;对 UNKNOWN 执行的 3 次快速连续 GET 只产生 1 次 worker status 调用;303 个 session 的 LOST 代际(以及 150 execution + 251 session 的混合代际)在一次warm内排空;非回环绑定被拒绝、显式 opt-in 放行;无视 SIGTERM 的 worker 在 release、close() 与 JVM 退出中途(forked-JVM 实证)都被强杀。mvn checkstyle:check干净。在packages/sdk-java/managed-agent-server中mvn test -Dtest='EmbeddedRuntimeBrokerTest,ManagedAgentPropertiesTest'通过(19 个),覆盖新的allow-non-loopback/v3-result-window接线。前后对比证据
N/A —— 非 UI 变更。前后行为由回归测试钉住:同一套复现交错在基线提交上失败、在本分支上通过。
测试平台
macOS ✅;Windows⚠️ 未测;Linux ⚠️ 未测。
环境(可选)
JDK 21 + Maven 3.9.16,H2 MySQL 模式。模块自带的
mysql-integrationfailsafe profile 可对真实 MySQL 复跑仓库层,此处未运行。风险与范围
allow-non-loopback。allow-non-loopback默认false——将 broker 绑定到回环之外的部署必须显式开启。DurableRuntimeRecoveryTest.lostBranchReclaimsBeyondTheReconcileDeadline与两个ToolPublicationStoreTest用例在负载下计时敏感。managed-runtime-broker-service-core与managed-runtime-broker-jdbc中已过时的机制陈述。关联 Issue
Refs #13183——高危发现与两个 Medium 项在此修复;其余 Medium 项在 #13202、#13203、#13204 跟进。