Skip to content

fix(sdk-java): Harden daemon transport reliability - #7603

Merged
wenshao merged 4 commits into
QwenLM:mainfrom
doudouOUC:fix/sdk-java-daemon-event-epoch
Jul 24, 2026
Merged

fix(sdk-java): Harden daemon transport reliability#7603
wenshao merged 4 commits into
QwenLM:mainfrom
doudouOUC:fix/sdk-java-daemon-event-epoch

Conversation

@doudouOUC

@doudouOUC doudouOUC commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This follow-up to #7463 adapts the Java daemon SDK to the restart-safe event cursor contract introduced by #7458. Prompt admission captures the optional eventEpoch paired with lastEventId, exposes it through PromptAcceptance, and sends it as X-Qwen-Event-Epoch on the initial SSE request and every reconnect. The client validates epoch tokens, can learn an epoch from a validated SSE response, retains a known epoch when a response omits the header, and fails closed before consuming events if the daemon reports a different epoch.

The Java transport now also handles the remaining validated wire-level reliability follow-ups: bare carriage-return SSE line endings and a leading UTF-8 BOM are accepted, non-ASCII cursor digits and malformed or truncated JSON are rejected deterministically, CRLF bytes count against the SSE frame limit, and unsupported REST response compression cannot be mistaken for a valid response. Non-success compressed responses preserve their HTTP status classification, while an undecodable successful prompt admission remains outcome-unknown rather than being retried.

Repeated id-less cancellation broadcasts are deduplicated while an ACP session is idle, and the latch resets when the next prompt starts. Java SDK CI jobs now have explicit time limits. The release workflow validates all Maven Central and GPG credentials before creating an immutable tag, uses bounded Central availability polling, and fails consistently on shell pipeline errors. The real-daemon E2E runner handles SIGINT and SIGTERM, bounds the Maven test, terminates detached POSIX process groups with TERM/KILL escalation, and reports both test and cleanup failures when they occur together.

Fault-injection tests cover admission propagation, response-header learning, reconnect retention, malformed epochs, stale cursors, SSE framing variants, truncated JSON, compressed 2xx/4xx/5xx responses, and idle cancel deduplication. The real-daemon E2E confirms that the current qwen serve prompt admission supplies an epoch and reaches a reliable terminal.

Why it's needed

After #7458, the daemon can distinguish an SSE cursor from a previous event-bus epoch only when the client returns the epoch paired with that cursor. The Java SDK previously ignored the new admission field and response header, so a reconnect after daemon restart could rely only on numeric event IDs and silently skip or accept events from the wrong epoch once the new bus caught up to the stale cursor.

The additional follow-ups close concrete failure modes found while reviewing #7463: standards-compliant SSE variants could be rejected or under-counted, truncated JSON could receive a misleading error, compressed REST responses could corrupt admission/error classification, repeated idle cancel requests could emit duplicate events, and failed or interrupted CI/E2E runs could leave child processes behind. Release credentials were also checked only after a tag could already have been created. The changes preserve the alpha SDK's fail-closed boundary without adding automatic snapshot/resync or claiming exactly-once execution across daemon restarts.

Reviewer Test Plan

How to verify

  1. From packages/sdk-java/qwencode, run mvn --batch-mode --no-transfer-progress clean test checkstyle:check. Expect 121 tests with zero failures or errors, five environment-dependent tests skipped, and zero Checkstyle violations.
  2. From packages/acp-bridge, run npx vitest run src/bridge.test.ts. Expect all 446 tests to pass, including repeated idle cancellation deduplication and reset after a new prompt starts.
  3. From the repository root, run npm run build && npm run typecheck && npm run bundle && npx tsx scripts/run-java-daemon-sdk-e2e.ts. Expect four Java E2E scenarios to pass against a real local qwen serve, with a non-null event epoch and reliable prompt terminals.
  4. Send SIGTERM to the E2E runner after startup. Expect it to exit with the corresponding signal status and leave no daemon or Maven test process. Review the fault-injection tests and confirm that malformed framing, unsupported compression, event gaps, and epoch mismatches fail closed without retrying mutations.
  5. Review the release workflow conditions. A production run with a missing artifact and any missing Maven/GPG environment secret must fail before tag creation; dry runs must not require publishing credentials, create tags, publish artifacts, or create a GitHub Release.

Current CI evidence for commit 254e37362d: the Java SDK matrix passed on Ubuntu with Java 11, 17, and 21, and on macOS and Windows with Java 21; the real-daemon E2E passed on Ubuntu with Java 11.

Evidence (Before & After)

N/A — this is a non-UI transport, bridge, CI, and release reliability change.

Tested on

OS Status
🍏 macOS ✅ tested locally
🪟 Windows ⚠️ not tested locally; CI configured
🐧 Linux ⚠️ not tested locally; CI configured

Environment (optional)

macOS Darwin 25.4.0 arm64, Java 21.0.8 LTS, Node.js 22.22.3, and npm 10.9.8.

Risk & Scope

  • Main risk or tradeoff: The transport intentionally treats invalid or changed epochs, malformed frames, unsupported compression on successful responses, and missing reliable terminals as indeterminate outcomes. This can surface more explicit failures but prevents silent partial success or unsafe mutation retries.
  • Not validated / out of scope: Automatic snapshot/resync recovery, true prompt-ID-targeted cancel, and exactly-once execution across daemon restarts remain daemon contract work. Windows process-tree termination is not added because the real-daemon E2E job runs on Linux; Windows Java unit coverage remains in CI. Maven Central credentials and environment protection rules must still be configured in the repository settings before production publication.
  • Breaking changes / migration notes: No breaking API changes. PromptAcceptance#getEventEpoch() is additive and may return null for older daemons.

Linked Issues

Related: #7458, #7463

中文说明

此 PR 的内容

这是 #7463 的后续补丁,使 Java daemon SDK 适配 #7458 引入的重启安全事件游标契约。prompt admission 现在会读取与 lastEventId 配对的可选 eventEpoch,通过 PromptAcceptance 暴露该值,并在首次 SSE 请求和每次重连时通过 X-Qwen-Event-Epoch 回传。客户端会校验 epoch token,可以从已验证的 SSE 响应中学习 epoch,在响应缺少 header 时保留已知 epoch,并在 daemon 返回不同 epoch 时于消费事件前 fail closed。

Java transport 也补齐了其余经过验证的 wire-level 可靠性项:接受仅使用回车符的 SSE 换行和开头的 UTF-8 BOM;确定性拒绝非 ASCII 游标数字以及非法或截断的 JSON;将 CRLF 的两个字节都计入 SSE frame 上限;不再把不支持的 REST 响应压缩误认为有效响应。压缩的非成功响应会保留其 HTTP 状态分类,而无法解码的成功 prompt admission 会返回结果未知,且不会自动重试。

ACP session 空闲时重复的无 prompt ID 取消广播会被去重,并在下一个 prompt 开始时重置去重状态。Java SDK CI job 增加了明确的时间上限。发布工作流会在创建不可变 tag 前校验全部 Maven Central 和 GPG 凭证,使用有界的 Central 可用性轮询,并在 shell pipeline 出错时一致失败。真实 daemon E2E runner 会处理 SIGINT 和 SIGTERM、限制 Maven 测试时长、通过 TERM/KILL 升级关闭独立的 POSIX 进程组,并在测试与清理同时失败时同时报告两者。

故障注入测试覆盖 admission 传播、响应头学习、重连保留、非法 epoch、旧游标、SSE framing 变体、截断 JSON、压缩的 2xx/4xx/5xx 响应以及空闲取消去重。真实 daemon E2E 确认当前 qwen serve 的 prompt admission 会提供 epoch,并能到达可靠终态。

为什么需要此改动

#7458 合入后,只有客户端回传与 SSE 游标配对的 epoch,daemon 才能识别该游标是否来自上一轮 event-bus epoch。Java SDK 之前忽略了新增的 admission 字段和响应 header,因此 daemon 重启后的重连只能依赖数字事件 ID;当新 event bus 追上旧游标时,客户端可能静默跳过或接受错误 epoch 的事件。

其余 follow-up 关闭了审查 #7463 时发现的具体失败模式:符合标准的 SSE 变体可能被拒绝或少计字节,截断 JSON 可能得到误导性错误,压缩 REST 响应可能破坏 admission/错误分类,重复的空闲 cancel 请求可能产生重复事件,失败或中断的 CI/E2E 运行可能遗留子进程。此外,发布凭证此前要到 tag 可能已经创建后才会被检查。这些改动保持 alpha SDK 的 fail-closed 边界,不新增自动 snapshot/resync,也不宣称跨 daemon 重启 exactly-once。

Reviewer 验证计划

验证方法

  1. packages/sdk-java/qwencode 运行 mvn --batch-mode --no-transfer-progress clean test checkstyle:check。预期 121 个测试零失败、零错误,跳过 5 个依赖环境的测试,Checkstyle 零违规。
  2. packages/acp-bridge 运行 npx vitest run src/bridge.test.ts。预期 446 个测试全部通过,包括重复空闲取消去重,以及新 prompt 开始后重置去重状态。
  3. 在仓库根目录运行 npm run build && npm run typecheck && npm run bundle && npx tsx scripts/run-java-daemon-sdk-e2e.ts。预期 4 个 Java E2E 场景在真实本地 qwen serve 上通过,包含非空 event epoch 和可靠 prompt 终态。
  4. 在 E2E runner 启动后发送 SIGTERM。预期其以对应信号状态退出,且不遗留 daemon 或 Maven 测试进程。检查故障注入测试,确认非法 framing、不支持的压缩、事件 gap 和 epoch mismatch 会 fail closed,且 mutation 不会被重试。
  5. 检查发布工作流条件。产物缺失且任一 Maven/GPG 环境 secret 缺失时,生产运行必须在 tag 创建前失败;dry run 不得要求发布凭证、创建 tag、发布制品或创建 GitHub Release。

提交 254e37362d 的当前 CI 证据:Java SDK 矩阵已在 Ubuntu 的 Java 11、17、21,以及 macOS 和 Windows 的 Java 21 上通过;真实 daemon E2E 已在 Ubuntu 的 Java 11 上通过。

证据(Before & After)

N/A——这是非 UI 的 transport、bridge、CI 和发布可靠性改动。

测试平台

OS 状态
🍏 macOS ✅ 已在本地测试
🪟 Windows ⚠️ 本地未测试;已配置 CI
🐧 Linux ⚠️ 本地未测试;已配置 CI

环境(可选)

macOS Darwin 25.4.0 arm64、Java 21.0.8 LTS、Node.js 22.22.3、npm 10.9.8。

风险与范围

  • 主要风险或取舍:transport 会有意把非法或变化的 epoch、非法 frame、成功响应上的不支持压缩以及缺失可靠终态视为结果不确定。这可能暴露更多明确失败,但可以避免静默的部分成功或不安全的 mutation 重试。
  • 未验证或范围外:自动 snapshot/resync 恢复、真正基于 prompt ID 的 targeted cancel,以及跨 daemon 重启 exactly-once 仍属于 daemon 契约工作。由于真实 daemon E2E job 在 Linux 上运行,本 PR 不新增 Windows 进程树终止;Windows Java 单元覆盖仍由 CI 提供。正式发布前仍需在仓库设置中配置 Maven Central 凭证和环境保护规则。
  • 破坏性变更与迁移说明:无破坏性 API 变更。新增 PromptAcceptance#getEventEpoch(),连接旧 daemon 时可能返回 null

关联事项

相关:#7458#7463

Pair SSE cursors with the daemon event epoch, learn validated response epochs, and fail closed when the epoch changes during prompt observation.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed protocol gap. #7458 (merged) added epoch tokens to the daemon's admission response and SSE headers; #7463 (merged) added the Java daemon transport but didn't propagate epochs. After a daemon restart, the Java SDK's SSE reconnect would rely solely on numeric event IDs and could silently skip or accept events from the wrong epoch. The TypeScript SDK already implements this contract — the Java SDK is the missing piece.

Direction: aligned. This completes an existing daemon contract (#7458) for the Java transport. The daemon already emits eventEpoch in the 202 response and X-Qwen-Event-Epoch on SSE; this PR makes the Java client participate. No new scope, just compliance.

Size: not applicable — no core module paths touched (packages/sdk-java only).

Approach: scope feels right. Three production files (DaemonClient, DaemonSessionClient, PromptAcceptance) gain epoch threading, validation, and fail-closed mismatch detection — mirroring what the TypeScript SDK already does. Docs updated consistently. No drive-by changes.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的协议缺口。#7458(已合入)为 daemon 的 admission 响应和 SSE header 添加了 epoch token;#7463(已合入)添加了 Java daemon transport 但未传播 epoch。daemon 重启后,Java SDK 的 SSE 重连仅依赖数字事件 ID,可能静默跳过或接受错误 epoch 的事件。TypeScript SDK 已实现此契约——Java SDK 是缺失的一环。

方向:对齐。这是为 Java transport 补全已有的 daemon 契约(#7458)。daemon 已在 202 响应中发出 eventEpoch、在 SSE 中发出 X-Qwen-Event-Epoch;此 PR 让 Java 客户端参与。无新范围,仅是合规。

规模:不适用——未触及核心模块路径(仅 packages/sdk-java)。

方案:范围合理。三个生产文件(DaemonClient、DaemonSessionClient、PromptAcceptance)增加 epoch 传递、校验和 fail-closed 不匹配检测——与 TypeScript SDK 已有实现一致。文档同步更新。无夹带改动。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 566bda578530c4084d533cc66c8c847a8f9dbe18 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: Given the daemon's epoch contract (#7458), the Java SDK needs to: (1) parse eventEpoch from the 202 admission, (2) send it as X-Qwen-Event-Epoch on SSE requests, (3) validate the response header, (4) fail closed on mismatch, (5) learn from response when admission lacked it, (6) retain when response omits it.

Comparison with the diff: The PR implements exactly this, cleanly.

  • PromptAcceptance gains a nullable eventEpoch field — additive, no breaking change.
  • DaemonClient.openSse() conditionally adds the header when epoch is non-null. Single caller, clean signature change.
  • DaemonSessionClient.observe() threads the epoch through the reconnect loop. The mismatch check fires before event consumption (after header validation, before watchdog scheduling) — correct fail-closed ordering.
  • EVENT_EPOCH_PATTERN ([A-Za-z0-9_-]{1,64}) matches the daemon's wire grammar in sse-last-event-id.ts exactly.
  • responseEventEpoch() rejects multiple header values — good defensive check.
  • Malformed admission epoch → PromptAdmissionUnknownException (fail closed before observation starts). Malformed response epoch → DaemonProtocolException → indeterminate. Both correct.

No correctness bugs, no security holes, no regressions spotted. The implementation mirrors the TypeScript SDK's existing epoch handling.

Tests: Six new fault-injection tests cover admission propagation, response learning, reconnect retention, epoch mismatch, malformed response epoch, and malformed admission epoch. E2E asserts the real daemon supplies a non-null epoch.

CI Results (real-scenario evidence)

This is a non-UI Java transport change — no TUI to exercise via tmux. The Java test suite and real-daemon E2E are the appropriate verification:

ubuntu-latest / Java 11   pass    57s
ubuntu-latest / Java 17   pass    37s
ubuntu-latest / Java 21   pass    41s
windows-latest / Java 21  pass    49s
Real daemon E2E / Java 11 pass (success)

macos-latest / Java 21    fail    40s
  └─ DaemonSessionClientTest.slowSseLineBytesKeepIdleWatchdogAlive
     "SSE reconnect attempts exhausted"

The macOS failure is a pre-existing timing-sensitive test (idle watchdog under slow line delivery) — unrelated to epoch changes. It exercises the reconnect-exhaustion path under artificial timing pressure, not the epoch logic. Ubuntu and Windows pass all 113 tests; the real-daemon E2E confirms epoch propagation end-to-end.

中文说明

代码审查

独立方案: 基于 daemon 的 epoch 契约(#7458),Java SDK 需要:(1) 从 202 admission 解析 eventEpoch,(2) 在 SSE 请求中发送 X-Qwen-Event-Epoch,(3) 校验响应 header,(4) 不匹配时 fail closed,(5) admission 缺少时从响应学习,(6) 响应省略时保留已知值。

与 diff 对比: PR 完整且干净地实现了上述所有点。

  • PromptAcceptance 增加可空 eventEpoch 字段——纯增量,无破坏性变更。
  • DaemonClient.openSse() 在 epoch 非空时条件性添加 header。单一调用方,签名变更干净。
  • DaemonSessionClient.observe() 在重连循环中传递 epoch。不匹配检查在事件消费之前触发(header 校验之后、watchdog 调度之前)——正确的 fail-closed 顺序。
  • EVENT_EPOCH_PATTERN[A-Za-z0-9_-]{1,64})与 daemon 的 sse-last-event-id.ts 中的 wire 语法完全一致。
  • responseEventEpoch() 拒绝多值 header——良好的防御性检查。
  • 非法 admission epoch → PromptAdmissionUnknownException(观察开始前 fail closed)。非法响应 epoch → DaemonProtocolException → 结果不确定。两者均正确。

未发现正确性 bug、安全漏洞或回归。实现与 TypeScript SDK 已有的 epoch 处理一致。

测试: 六个新故障注入测试覆盖 admission 传播、响应学习、重连保留、epoch 不匹配、非法响应 epoch、非法 admission epoch。E2E 断言真实 daemon 提供非空 epoch。

CI 结果(真实场景证据)

这是非 UI 的 Java 传输层改动——无 TUI 可通过 tmux 验证。Java 测试套件和真实 daemon E2E 是合适的验证方式:

macOS 失败是预存的时序敏感测试(慢行投递下的空闲 watchdog)——与 epoch 改动无关。Ubuntu 和 Windows 通过全部 113 个测试;真实 daemon E2E 确认 epoch 端到端传播。

Qwen Code · qwen3.8-max-preview

Reviewed at 566bda578530c4084d533cc66c8c847a8f9dbe18 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean, focused completion of an existing daemon contract; would merge without hesitation.

This is exactly what the Java SDK needed after #7458 landed. The epoch threading is minimal (three production files, ~65 lines of logic), the validation grammar matches the daemon's wire format character-for-character, and the fail-closed ordering is correct — mismatch detection fires before a single event is consumed. The six fault-injection tests cover every path I'd want verified: propagation, learning, retention, mismatch, and both malformed-input surfaces. The real-daemon E2E confirms the daemon actually supplies an epoch end-to-end.

The macOS CI flake (slowSseLineBytesKeepIdleWatchdogAlive) is a pre-existing timing test that exercises reconnect exhaustion under artificial slow-line pressure — it has nothing to do with epoch logic and passes on Ubuntu and Windows.

No reservations. Ships the feature cleanly, tests are thorough, docs are consistent.

中文说明

置信度:5/5 —— 干净、聚焦地补全已有 daemon 契约;毫不犹豫可合入。

这正是 #7458 合入后 Java SDK 所需要的。epoch 传递极为精简(三个生产文件,约 65 行逻辑),校验语法与 daemon 的 wire 格式逐字符一致,fail-closed 顺序正确——不匹配检测在消费任何事件之前触发。六个故障注入测试覆盖了我希望验证的每条路径:传播、学习、保留、不匹配,以及两个非法输入面。真实 daemon E2E 确认 daemon 端到端提供 epoch。

macOS CI 抖动(slowSseLineBytesKeepIdleWatchdogAlive)是预存的时序测试,在人为慢行压力下触发重连耗尽——与 epoch 逻辑无关,在 Ubuntu 和 Windows 上通过。

无保留意见。功能实现干净,测试充分,文档一致。

Qwen Code · qwen3.8-max-preview

Reviewed at 566bda578530c4084d533cc66c8c847a8f9dbe18 · re-run with @qwen-code /triage

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Java/Maven not available in review environment; CI check macos-latest / Java 21 is failing.

— qwen3.7-max via Qwen Code /review

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC doudouOUC changed the title fix(sdk-java): Propagate daemon event epochs fix(sdk-java): Harden daemon transport reliability Jul 23, 2026
@doudouOUC doudouOUC self-assigned this Jul 23, 2026
@doudouOUC
doudouOUC requested review from wenshao and yiliang114 July 23, 2026 16:07

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Review — fix(sdk-java): Harden daemon transport reliability @ 63378a8ab

Reviewed the full diff (17 files, +741/−103) against the merged result with current main. No blocking correctness defects found. The epoch plumbing is right, both fallback paths are fail-closed, and the two new bridge tests are genuinely discriminating (verified by mutation). Findings below are one test-coverage regression and a set of consistency/robustness nits.


What I verified (not just read)

Epoch contract matches the daemon exactly. The Java EVENT_EPOCH_PATTERN = [A-Za-z0-9_-]{1,64} + Matcher#matches() (whole-string anchored) mirrors parseEventEpochHeader's /^[\w-]+$/ + length ≤ 64 in packages/cli/src/serve/sse-last-event-id.ts, and daemon epochs are randomUUID() (eventBus.ts:281) — 36 chars in the accepted class. The client/server asymmetry is the right one: the server treats an invalid header as absent (falls back to the numeric heuristic), the client treats it as fatal. That is the correct direction for a fail-closed client.

Both fallback paths are fail-closed. If a daemon supplies eventEpoch on admission but omits the SSE response header, the client retains the epoch and keeps sending it; server-side stale-cursor detection then emits state_resync_required, which is in SESSION_FAILURE_EVENTS (DaemonSessionClient.java:34-36) → indeterminate. So the header path and the event path both terminate safely.

No stream/watchdog leak on the mismatch path. responseEventEpoch runs before scheduleIdleWatchdog/scheduleDeadlineWatchdog/new SseReader, inside the try whose finally calls requestStreamClose(stream), and the watchdog cancels are null-guarded. A mismatch throw can't leak either.

BufferedInputStream.mark(1) + one read() + reset() in the new bare-CR readLine() is safe. The mark-invalidation branch in BufferedInputStream#fill() requires markpos == 0 && pos >= buf.length; since pos == markpos at the moment of the peek, that is unreachable for a 1-byte lookahead. wireBytes accounting is correct in all three cases (CRLF → 2, bare CR → 1, CR-at-EOF → 1). BOM stripping is per-connection because SseReader is constructed fresh per SSE open (DaemonSessionClient.java:742), so firstLine can't leak across reconnects.

The Content-Encoding guard can't be tripped by the real daemon. Accept-Encoding: identity is already sent on REST as well as SSE (DaemonClient.java:510), the JDK HttpClient neither advertises nor auto-decodes gzip, and qwen serve has no compression middleware. So the new guard is purely a defence against a non-conforming intermediary — good, and it changes no happy path.

bridge.ts latch reset covers every prompt-start path. entry.promptActive = true / entry.activePromptId = … are assigned at exactly one site (bridge.ts:5439-5440), so the delete entry.cancelBroadcastWithoutPrompt on the next line is complete. I re-checked this on the merged tree with current main (which picked up #7388) — still a single site, latch reset still adjacent.

Mutation-verified the two new bridge tests (both are load-bearing, not vacuous):

Mutation Result
delete the delete entry.cancelBroadcastWithoutPrompt; line allows a new idle cancellation after another prompt starts fails (2 → 1)
replace the promptId === undefined && …WithoutPrompt === true disjunct with false deduplicates repeated cancellation broadcasts while idle fails

packages/acp-bridge full file at PR head: 444 passed / 2 failed — and the identical 2 failures (forwards childEnvOverrides…, rebuilds authoritative invocation metadata…) reproduce on the origin/main baseline 73279ccfc, so they are pre-existing/environmental on this machine, not caused by this PR. Merges cleanly into current main (git merge-tree --write-tree, exit 0).

I could not run the Java suite locally (no JDK/Maven on this box); relying on CI, which is green at this head across ubuntu 11/17/21, macOS 21, Windows 21, plus the real-daemon E2E.


Findings

1. (Medium — test coverage) An existing regression guard was repurposed, losing the uncompressed retryable-admission path

DaemonSessionClientTest.retryableHttpPromptFailureIsAdmissionUnknownAndNotRetried (line 2677) was edited in place from sendJson(exchange, 502, …) to sendEncodedJson(exchange, 502, "gzip", …). That test was the guard for "a retryable HTTP status on prompt admission is outcome-unknown and is never retried" over the normal wire. It now exercises a materially different code path: the new Content-Encoding branch short-circuits in HttpSupport.consume before the body is read, so the original decode-and-classify path is no longer covered for a retryable status.

Enumerating every non-2xx status the tests stub for POST /session/:id/prompt confirms the gap:

status uncompressed gzip
202 (success) ✅ many ✅ line 2708
400 (definitive) ✅ line 1960
429 (definitive) ✅ line 2751
409 (definitive) ✅ line 2728
408 / 5xx (retryable) ❌ none ✅ line 2681

The other 502/503 stubs in the file (lines 1712–1725) are for heartbeat/permission/cancel/delete, not prompt admission.

Suggested fix: restore the original sendJson(exchange, 502, …) body in retryableHttpPromptFailureIsAdmissionUnknownAndNotRetried, and add compressedRetryableHttpPromptFailureIsAdmissionUnknownAndNotRetried as a sibling. Both invariants then have a dedicated guard.

2. (Low — test strength) The three fail-closed epoch tests don't actually prove "no reconnect"

preventsStaleCursorFromAcceptingNewEpochSuffix, failsClosedOnMalformedSseResponseEpoch, and failsClosedOnDuplicateSseResponseEpochHeaders each use a stub that returns the same bad response on every connection. A client that reconnected up to maximumReconnectAttempts would still surface the same PromptOutcomeIndeterminateException, so all three pass either way. preventsStaleCursor… asserts on an AtomicReference that is set() on every request — overwritten, not counted — so it doesn't close the gap either.

Reading the code, the behaviour is right (catch (DaemonProtocolException e) { throw indeterminate(…) } sits outside the reconnect branches). But "fails closed before consuming events, without retrying" is the PR's headline claim, and it is currently unpinned. Adding an AtomicInteger subscriptions and assertEquals(1, subscriptions.get()) to each is a two-line change per test.

3. (Low) SSE error bodies bypass the new Content-Encoding guard

HttpSupport.consume now rejects/annotates non-identity encodings, and validateSseHeaders checks Content-Encoding — but only on the 200 branch. A non-200 SSE response goes through readSseErrorHttpSupport.readError(InputStream, …), which has no such check, so a gzip-encoded SSE error body is decoded as raw UTF-8 and lands as mojibake inside the DaemonHttpException message. Cosmetic (classification is by status code and stays correct), but the guard is cheap here and would make the treatment symmetric with the REST path this PR just hardened.

4. (Low — consistency) Retry-After still accepts Unicode digits

The SseReader hardening is a real catch: Long.parseLong delegates to Character.digit, so id: ١ (U+0661) really did parse as 1 before isAsciiDigits. But DaemonSessionClient.retryAfter (line 1103) still does Long.parseLong(value.trim()) on the Retry-After header, so the identical Unicode-digit acceptance survives at that boundary.

Impact is nil in practice — reconnectOrThrow line 910 clamps to Math.min(5000L, …) and then to remainingMillis — so this is a consistency nit, not a bug. Flagging it only because if the intent is "deterministic numeric parsing at every wire boundary", this is the one that got missed.

5. (Low) Require an unchanged source tree still fails open

The PR adds set -euo pipefail to four run blocks in release-sdk-java.yml, but leaves it off Require an unchanged source tree (line 165), which evaluates [[ -n "$(git status --porcelain)" ]]. If git status errors, the substitution is empty, the test is false, and the step passes silently — a release gate failing open.

Worth noting for the PR description too: GHA's default shell on Linux is bash -e {0}, so -e was already active on every one of these steps. The real additions are -u and pipefail, and this step is the one place where the missing error-check actually changes a release-gating decision. (Require protected main at line 31 also lacks it, but has no command substitution, so it's harmless.)

6. (Info) Central-availability worst case vs. the new 60-minute job cap

Verify Maven Central availability went from 30 × 10s (≈5 min) to 40 × (30s sleep + ≤10s curl) → up to ~26 min, inside a timeout-minutes: 60 job that also runs mvn verify, npm ci, build, bundle, the Java E2E, and mvn deploy. Bounding the curl is unambiguously right (an unbounded connect could previously hang the job indefinitely). The note is just that on a slow Central day the job can now hit the wall clock after the tag was pushed and the artifact deployed, leaving only the GitHub Release step un-run. That looks recoverable via the tag_state=resume / artifact_state=published preflight path on a re-run — worth confirming that's the intended recovery story rather than manual cleanup.

Separately: the credential gate's coverage is sound. tag_state == 'new' implies artifact_state == 'missing' (preflight lines 118–121 hard-fail the new + published combination), so every path that can create the immutable tag is preceded by the credential check. That's the important property and it holds.

7. (Nit) detached: true trades one orphan mode for another

Putting the daemon and Maven in their own process groups is what makes the TERM/KILL escalation reach grandchildren — good. The flip side is that a SIGKILL of the runner (or an outer wrapper's kill -9) now leaves them running, where previously a terminal-wide ^C would have reached them via the shared foreground group. The new SIGINT/SIGTERM handlers cover the normal cases and CI container teardown covers the rest, so this is an accepted trade — just worth one line of comment in the script so nobody later "simplifies" the handlers away and reintroduces the orphan.


Nits

  • DaemonClient.openSse: HttpRequest.Builder request / HttpRequest builtRequest reads awkwardly — builder / request would be the conventional pairing.
  • HttpSupport.consume: throw new DaemonProtocolException(\n diagnostic); is wrapped across two lines for no reason (fits on one).
  • docs/design/java-daemon-sdk-alpha.md: the reflow leaves cover one-local-prompt admission, stranded on a short line. Harmless (proseWrap is preserve), just untidy.
  • The BOM's 3 bytes still count toward maximumFrameBytes for the first frame. Irrelevant at any realistic limit.

Verdict

Approve after #1. The transport logic is careful and the fail-closed boundary is preserved end-to-end; the epoch work is a genuine fix for the post-#7458 gap and is correctly conservative. #1 is a two-minute fix and is the only one I'd hold the merge for — restoring a deleted regression guard matters more than the new coverage it was traded for. #2#7 are follow-up material.

中文说明

结论

63378a8ab 上审阅了完整 diff(17 文件,+741/−103),并在与当前 main 的合并结果上复核。未发现阻塞性正确性缺陷。 epoch 链路正确,两条回退路径都是 fail-closed,两个新增 bridge 测试经变异验证确实有判别力。以下是一个测试覆盖回归和若干一致性/健壮性问题。

已实测验证的部分

  • epoch 契约与 daemon 完全一致:Java 的 [A-Za-z0-9_-]{1,64} + matches()(全串锚定)与 sse-last-event-id.tsparseEventEpochHeader/^[\w-]+$/ + length ≤ 64 对齐;daemon 的 epoch 是 randomUUID()eventBus.ts:281)。服务端把非法 header 当作「未提供」、客户端当作「致命」,这个不对称方向对 fail-closed 客户端是正确的。
  • 两条回退路径都 fail-closed:daemon 只在 admission 给 epoch、SSE 响应不带 header 时,客户端保留 epoch 继续回传,服务端 stale-cursor 检测发出 state_resync_required,而该事件在 SESSION_FAILURE_EVENTSDaemonSessionClient.java:34-36)中 → indeterminate
  • mismatch 路径无 stream/watchdog 泄漏responseEventEpoch 在 watchdog 调度和 SseReader 构造之前执行,位于 finally 会调用 requestStreamClose(stream)try 内,watchdog 取消也有 null 保护。
  • 新的 bare-CR readLine()mark(1) + 单次 read() + reset() 是安全的BufferedInputStream#fill() 使 mark 失效的分支要求 markpos == 0 && pos >= buf.length,而 peek 时刻 pos == markpos,故 1 字节前瞻下不可达。三种情况的 wireBytes 计数均正确。BOM 剥离是每连接的(SseReader 每次 SSE 打开都新建,DaemonSessionClient.java:742),不会跨重连泄漏。
  • Content-Encoding 守卫不会被真实 daemon 触发:REST 与 SSE 都已发送 Accept-Encoding: identityDaemonClient.java:510),JDK HttpClient 既不声明也不自动解压 gzip,qwen serve 也没有 compression 中间件。
  • bridge.ts 的 latch 重置覆盖所有 prompt 起点promptActive = true / activePromptId 只在 bridge.ts:5439-5440 一处赋值,delete 紧随其后;在与当前 main(含 feat(daemon): add explicit channel delivery #7388)的合并树上复核,仍是唯一一处。
  • 变异验证:删掉 delete entry.cancelBroadcastWithoutPrompt;allows a new idle cancellation after another prompt starts 失败(2 → 1);把 promptId === undefined && …WithoutPrompt === true 换成 falsededuplicates repeated cancellation broadcasts while idle 失败。两个测试都有判别力。
  • packages/acp-bridge 整文件:444 通过 / 2 失败,且相同的 2 个失败在 origin/main 基线 73279ccfc 上同样复现,属本机环境的既有问题,与本 PR 无关。可干净合入当前 main
  • 本机无 JDK/Maven,未能本地运行 Java 套件;依据 CI——该 head 上 ubuntu 11/17/21、macOS 21、Windows 21 及真实 daemon E2E 全绿。

问题

  1. (Medium — 测试覆盖)既有回归守卫被就地改写,未压缩的可重试 admission 路径失去覆盖。 retryableHttpPromptFailureIsAdmissionUnknownAndNotRetried(2677 行)从 sendJson(exchange, 502, …) 就地改为 sendEncodedJson(exchange, 502, "gzip", …)。新的 Content-Encoding 分支在读 body 之前短路,因此该测试现在走的是另一条路径。穷举所有为 POST /session/:id/prompt 打桩的非 2xx 状态可确认:400、429/409、202 都有未压缩覆盖,408 / 5xx 的未压缩路径已无任何测试(1712–1725 行的 502/503 属于 heartbeat/permission/cancel/delete,不是 prompt admission)。建议恢复原测试并另加 gzip 变体作为兄弟用例。
  2. (Low — 测试强度)三个 fail-closed epoch 测试并未证明「不重连」。 preventsStaleCursorFromAcceptingNewEpochSuffixfailsClosedOnMalformedSseResponseEpochfailsClosedOnDuplicateSseResponseEpochHeaders 的桩每次连接都返回同样的坏响应,即使客户端重连到上限也会抛出同一异常,测试仍会通过;preventsStaleCursor… 断言的 AtomicReference 每次请求都被覆盖而非计数。代码行为本身是对的,但「在消费事件前 fail closed 且不重试」是本 PR 的核心主张,目前未被钉死。每个测试加一个 AtomicInteger subscriptionsassertEquals(1, …) 即可。
  3. (Low)SSE 错误响应体绕过了新的 Content-Encoding 守卫。 validateSseHeaders 只在 200 分支执行;非 200 走 readSseErrorHttpSupport.readError(InputStream, …),没有该检查,gzip 错误体会以原始 UTF-8 解码成乱码进入 DaemonHttpException 消息。仅影响可读性(状态码分类仍正确),但补上成本很低,可与刚加固的 REST 路径对称。
  4. (Low — 一致性)Retry-After 仍接受 Unicode 数字。 SseReader 的加固是真问题(Long.parseLongCharacter.digitid: ١ 之前确实会被解析成 1);但 DaemonSessionClient.retryAfter(1103 行)仍是 Long.parseLong(value.trim())。因 910 行有 Math.min(5000L, …)remainingMillis 双重钳制,实际影响为零,仅作一致性提示。
  5. (Low)Require an unchanged source tree 仍会 fail open。 本 PR 给 4 个 run 块加了 set -euo pipefail,但 165 行这个步骤没加,其 [[ -n "$(git status --porcelain)" ]]git status 出错时命令替换为空 → 条件为假 → 静默通过,这是一个会 fail open 的发布闸门。另外 PR 描述可修正:GHA 在 Linux 上默认 shell 是 bash -e {0}-e 本就生效,真正新增的是 -upipefail
  6. (Info)Central 可用性轮询的最坏耗时 vs. 新的 60 分钟 job 上限。 从 30 × 10s(约 5 分钟)变为 40 × (30s sleep + ≤10s curl),最坏约 26 分钟,而同一个 60 分钟的 job 还要跑 mvn verifynpm ci、build、bundle、Java E2E 和 mvn deploy。给 curl 加上界是明确正确的(此前无界连接可能永久挂住 job);提示点仅在于 Central 慢的时候,job 可能在 tag 已推送、制品已部署之后耗尽时间,只剩 GitHub Release 未执行。看起来可通过 preflight 的 tag_state=resume / artifact_state=published 重跑恢复,建议确认这就是预期的恢复路径。另:凭证闸门的覆盖是完备的——preflight 118–121 行硬失败了 new + published 组合,因此 tag_state == 'new' 蕴含 artifact_state == 'missing',所有能创建不可变 tag 的路径都先过了凭证检查。
  7. (Nit)detached: true 是一种孤儿模式的置换。 让 daemon 与 Maven 独立成组,正是 TERM/KILL 升级能触达孙进程的前提;代价是 runner 被 SIGKILL 时它们会残留,而此前共享前台进程组时终端 ^C 能触达。新增的信号处理覆盖了常规场景,CI 容器回收覆盖其余,属可接受权衡——建议在脚本里加一行注释,避免日后有人「简化」掉信号处理而重新引入孤儿进程。

小问题

  • DaemonClient.openSseHttpRequest.Builder request / HttpRequest builtRequest 命名别扭,builder / request 更常规。
  • HttpSupport.consumethrow new DaemonProtocolException(\n diagnostic); 无谓换行。
  • docs/design/java-daemon-sdk-alpha.md:重排后 cover one-local-prompt admission, 单独占一短行。无害(proseWrappreserve),仅不整洁。
  • 首帧的 BOM 3 字节仍计入 maximumFrameBytes,在任何现实上限下都无影响。

结论

修完 #1 即可合入。 transport 逻辑严谨,fail-closed 边界端到端保持;epoch 工作确实修复了 #7458 之后的缺口且保守得当。#1 是两分钟的修改,也是我唯一会因此拦住合并的一项——恢复被删掉的回归守卫,比它换来的新覆盖更重要。#2#7 可作为后续处理。

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Thanks @wenshao — addressed the merge-blocking coverage regression in 0b5335638b.

Finding Action Result
#1 uncompressed retryable admission coverage Fixed Restored the original uncompressed 502 prompt-admission regression guard and kept gzip coverage as a separate sibling test.
#2 no-reconnect assertions Deferred The implementation is already fail-closed; this is additional low-severity test strengthening after repeated review rounds.
#3 compressed SSE error-body readability Deferred HTTP status classification remains correct; this is a low-severity diagnostic consistency improvement.
#4 Unicode Retry-After digits Deferred Retry delay is bounded and the reviewer confirmed practical impact is nil.
#5 source-tree gate shell strictness Deferred Low-severity release-workflow hardening, outside the sole merge-blocking finding for this late review round.
#6 Central timeout recovery Confirmed A rerun resumes from the immutable tag and already-published artifact, then completes Maven Central verification and GitHub Release creation.
#7 detached process-group tradeoff and nits Deferred Accepted tradeoff or non-blocking cleanup; no additional churn in this review round.

Verification: DaemonSessionClientTest passed 98/98. The final Java gate passed 123 tests with zero failures or errors, five environment-dependent skips, and zero Checkstyle violations.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Code Review — fix(sdk-java): Harden daemon transport reliability

Re-review at head 0b5335638. Verdict: ✅ merge-ready. The single blocking finding from the prior round is fully resolved; CI is green across the Java matrix (Ubuntu 11/17/21, macOS 21, Windows 21) and the real-daemon E2E.

What this PR does

Follow-up to #7463 that adapts the Java daemon SDK to the restart-safe event-cursor contract from #7458, plus the remaining wire-level reliability follow-ups found while reviewing #7463:

  • Event epochs — admission captures the optional eventEpoch paired with lastEventId, exposes it via PromptAcceptance#getEventEpoch(), sends it as X-Qwen-Event-Epoch on the initial SSE GET and every reconnect, learns an epoch from a validated SSE response, retains a known epoch when a response omits the header, and fails closed before consuming events when the epoch changes mid-observation.
  • SSE framing — accepts bare-CR line endings and a leading UTF-8 BOM, rejects non-ASCII cursor digits, and counts both CRLF bytes against the frame limit.
  • REST — rejects unsupported Content-Encoding on 2xx while preserving HTTP status classification on non-2xx; truncated JSON now reports "incomplete" deterministically.
  • Bridge — deduplicates id-less idle cancel broadcasts, resetting the latch when the next prompt starts.
  • CI / release / E2E — job time limits, credential preflight before immutable tag creation, bounded Central polling, and a SIGINT/SIGTERM-aware E2E runner that terminates detached POSIX process groups.

✅ Prior blocker resolved (test coverage)

Last round the sole blocker was that retryableHttpPromptFailureIsAdmissionUnknownAndNotRetried had been repurposed in place from sendJson(502)sendEncodedJson(502,"gzip"). Because the new Content-Encoding guard in HttpSupport.consume short-circuits before the body is read, the gzip variant is a different code path — so no test covered an uncompressed 5xx on POST /session/:id/prompt.

The final commit ("restore retryable admission coverage") reverts that in-place edit and adds the gzip cases as siblings. The admission-failure matrix is now complete on both axes:

Status Uncompressed gzip
202 (admission) ✅ (many) compressedPromptAdmissionIsOutcomeUnknown
409 (definitive) definitivePromptRejectionRemainsHttpError (429) / 400 compressedDefinitivePromptRejectionRemainsHttpError
502 (retryable) retryableHttpPromptFailureIsAdmissionUnknownAndNotRetried compressedRetryableHttpPromptFailureIsAdmissionUnknownAndNotRetried

Confirmed the restored test again uses uncompressed sendJson(exchange, 502, …) and asserts session-1/prompt is hit exactly once (no retry) → PromptAlreadyActiveException on reuse.

Verified-correct (tried to break, couldn't)

  • Epoch grammar matches the server exactly — Java [A-Za-z0-9_-]{1,64} + matches() ↔ server parseEventEpochHeader (/^[\w-]+$/, len ≤ 64); epochs are randomUUID(). Client-fatal / server-treats-invalid-as-absent is the correct asymmetry.
  • Epoch mismatch fails closed — the mid-observation change throws DaemonProtocolException before events are consumed and surfaces as PromptOutcomeIndeterminateException (never a silent retry), asserted by preventsStaleCursorFromAcceptingNewEpochSuffix (single request, epoch epoch-old).
  • Missing SSE header → indeterminatestate_resync_required is in SESSION_FAILURE_EVENTS, so the numeric-only fallback stays fail-closed.
  • Bare-CR readLine() is safeBufferedInputStream.mark(1) + peek + reset() cannot hit fill()'s mark-invalidation (pos == markpos at the peek).
  • Content-Encoding guard can't be tripped by the real daemonAccept-Encoding: identity is already sent on REST, the JDK HttpClient never auto-gzips, and qwen serve has no compression middleware; the guard is defense against a misconfigured proxy.
  • Unicode-digit fix is realCharacter.digit used to accept id: ١; isAsciiDigits now rejects it.
  • E2E signal handlingdetached:true puts children in their own process group and Ctrl+C now reaches them via process.kill(-pid, …) with TERM→KILL escalation; the runner re-raises the received signal for a correct exit status and reports run + cleanup failures together via AggregateError.

Non-blocking observations (Low / Info — no change requested)

  • Release-workflow consistency: the PR adds set -euo pipefail to four run steps but skips Require an unchanged source tree (still relies on the default bash -e shell + [[ -n "$(git status --porcelain)" ]]). Harmless — the default Linux shell already applies -e; adding it here would only be for -u/pipefail consistency.
  • SSE error bodies still bypass the encoding guard (readSseErrorHttpSupport.readError; validateSseHeaders is 200-only) — cosmetic, since a compressed error body only affects a diagnostic string.
  • The Retry-After HTTP-header parse still uses Long.parseLong on Unicode digits — harmless, clamped to 5 s.
  • Central-availability worst case grew from ~5 min to ~20 min (40 × 30 s) under the new 60-min job cap — intentional and within budget.

Summary

Clean, well-tested, fail-closed transport hardening with no breaking API surface (getEventEpoch() is additive, may be null for older daemons). The one gap from last round is closed and the coverage is now symmetric. Approve.

中文说明

在 head 0b5335638 复审。结论:✅ 可合并。 上一轮唯一的阻塞项已完全解决;CI 在 Java 矩阵(Ubuntu 11/17/21、macOS 21、Windows 21)与真实 daemon E2E 上全绿。

PR 内容#7463 的后续,使 Java daemon SDK 适配 #7458 的重启安全事件游标契约,并补齐审查 #7463 时发现的 wire-level 可靠性项:事件 epoch 传播与 fail-closed、SSE 裸 CR/BOM/CRLF 计数/非 ASCII 游标数字、REST 压缩响应拒绝、截断 JSON 判定、bridge 空闲 cancel 去重、CI/发布/E2E 加固。

上一轮阻塞项已解决(测试覆盖)retryableHttpPromptFailureIsAdmissionUnknownAndNotRetried 之前被就地改成 gzip 变体,导致没有测试覆盖未压缩的 5xx admission 路径(新的 Content-Encoding 守卫在读 body 前短路,是不同代码路径)。最后一个 commit 恢复了原测试(仍用未压缩 sendJson(502)),并把 gzip 用例作为兄弟测试新增。admission 失败矩阵现已在压缩/未压缩两个维度上完整(202/409/502)。

已验证正确(尝试证伪未果):epoch 语法与服务端完全一致;epoch mismatch 在消费事件前 fail closed → PromptOutcomeIndeterminateException;缺失 SSE header 走 state_resync_required 保持 indeterminate;裸 CR readLine()mark(1)/reset() 安全;Content-Encoding 守卫无法被真实 daemon 触发;Unicode 数字修复真实有效;E2E 信号处理正确(进程组 TERM→KILL 升级 + 重新抛出信号)。

非阻塞(Low/Info,不要求改动)Require an unchanged source tree 步骤未加 set -euo pipefail(一致性,非功能问题,默认 shell 已有 -e);SSE error body 绕过编码守卫(仅影响诊断字符串);Retry-After 仍用 Long.parseLong(无害,钳制到 5s);Central 轮询最坏耗时约 5min→20min(有意,预算内)。

总结:干净、测试充分、fail-closed 的 transport 加固,无破坏性 API(getEventEpoch() 为新增,旧 daemon 可能返回 null)。上一轮的覆盖缺口已闭合。Approve。

@wenshao
wenshao added this pull request to the merge queue Jul 24, 2026
Merged via the queue into QwenLM:main with commit edfb43e Jul 24, 2026
58 of 59 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants