Skip to content

fix(serve): reject fractional max sessions#5705

Closed
tt-a1i wants to merge 1 commit into
QwenLM:mainfrom
tt-a1i:fix/bridge-max-sessions-integer
Closed

fix(serve): reject fractional max sessions#5705
tt-a1i wants to merge 1 commit into
QwenLM:mainfrom
tt-a1i:fix/bridge-max-sessions-integer

Conversation

@tt-a1i

@tt-a1i tt-a1i commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Rejects fractional maxSessions values in the ACP session bridge.

The existing behavior for undefined, 0, and Infinity is preserved: undefined keeps the default session cap, while 0 and Infinity still mean unlimited.

Why it's needed

maxSessions is a count-based resource limit. Fractional values like 1.5 are not meaningful, but they were accepted and then used in a current >= maxSessions comparison.

That silently changes the effective limit. For example, maxSessions: 1.5 behaves like allowing 2 sessions. Failing at bridge construction is clearer and matches the nearby integer validation for maxPendingPromptsPerSession.

Reviewer Test Plan

How to verify

Run npm test --workspace=@qwen-code/acp-bridge -- bridge.test.ts and confirm the bridge tests pass.

Review the updated createAcpSessionBridge validation and confirm fractional maxSessions values now throw while 0 and Infinity remain valid unlimited sentinels.

Evidence (Before & After)

Before: createAcpSessionBridge({ maxSessions: 1.5 }) constructed a bridge and effectively allowed 2 sessions.

After: fractional maxSessions throws during bridge construction. Tests cover NaN, negative, and fractional rejection, plus 0 and Infinity acceptance.

Tested on

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

Environment (optional)

Local validation on macOS:

  • npm test --workspace=@qwen-code/acp-bridge -- bridge.test.ts
  • npx prettier --check packages/acp-bridge/src/bridge.ts packages/acp-bridge/src/bridge.test.ts
  • npm run lint --workspace=@qwen-code/acp-bridge --if-present
  • npm run typecheck --workspace=@qwen-code/acp-bridge --if-present
  • git diff --check

Risk & Scope

  • Main risk or tradeoff: embedded callers that passed fractional maxSessions will now fail fast instead of getting an implicit rounded-up effective cap.
  • Not validated / out of scope: no changes to session creation, reaping, or daemon HTTP route behavior beyond rejecting invalid bridge options.
  • Breaking changes / migration notes: valid integer values, 0, Infinity, and the default behavior are unchanged.

Linked Issues

Fixes #5704

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.

中文说明

What this PR does

这个 PR 会在 ACP session bridge 中拒绝小数形式的 maxSessions

原有的 undefined0Infinity 行为保持不变:undefined 使用默认 session 上限,0Infinity 仍然表示无限制。

Why it's needed

maxSessions 是基于数量的资源限制。像 1.5 这样的小数没有实际意义,但之前会被接受,并参与 current >= maxSessions 这样的比较。

这会静默改变实际限制。例如 maxSessions: 1.5 的效果接近允许 2 个 session。现在在 bridge 构造阶段直接失败更清晰,也和附近 maxPendingPromptsPerSession 的整数校验保持一致。

Reviewer Test Plan

How to verify

运行 npm test --workspace=@qwen-code/acp-bridge -- bridge.test.ts,确认 bridge 测试通过。

检查更新后的 createAcpSessionBridge 校验逻辑,确认小数 maxSessions 会抛错,同时 0Infinity 仍然是合法的无限制哨兵值。

Evidence (Before & After)

Before:createAcpSessionBridge({ maxSessions: 1.5 }) 可以构造 bridge,并且实际效果接近允许 2 个 session。

After:小数 maxSessions 会在 bridge 构造阶段抛错。测试覆盖了 NaN、负数、小数拒绝,以及 0Infinity 接受。

Tested on

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

Environment (optional)

本地 macOS 验证:

  • npm test --workspace=@qwen-code/acp-bridge -- bridge.test.ts
  • npx prettier --check packages/acp-bridge/src/bridge.ts packages/acp-bridge/src/bridge.test.ts
  • npm run lint --workspace=@qwen-code/acp-bridge --if-present
  • npm run typecheck --workspace=@qwen-code/acp-bridge --if-present
  • git diff --check

Risk & Scope

  • Main risk or tradeoff:传入小数 maxSessions 的 embedded caller 现在会快速失败,而不是得到一个隐式向上取整效果的实际上限。
  • Not validated / out of scope:不修改 session 创建、回收或 daemon HTTP route 行为,只拒绝无效 bridge options。
  • Breaking changes / migration notes:合法整数、0Infinity 和默认行为都保持不变。

Linked Issues

Fixes #5704

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.

@tt-a1i
tt-a1i marked this pull request as ready for review June 22, 2026 20:03
@wenshao

wenshao commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

On direction: this fixes a real, well-defined bug — maxSessions: 1.5 silently behaving like 2 is the kind of silent fail-OPEN that bites operators later. Aligns with issue #5704 and the existing integer validation pattern for maxPendingPromptsPerSession. Keeping the 0 / Infinity unlimited sentinels intact preserves backward compatibility. No CHANGELOG reference needed — this is a narrow bug fix in the right place.

On approach: scope is tight and appropriate. Two files, net +4 lines, no drive-by refactors. The validation ladder (isFiniteisInteger → non-negative) reads cleanly and the error message stays in line with existing style. One minor observation for Stage 2: the test now matches /maxSessions/ instead of the specific value-string regex, which is a small regression in assertion precision — worth a quick look at whether the original granularity was intentional or just incidental.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

方向:这个 PR 修了一个真实、明确的 bug —— maxSessions: 1.5 静默表现得像 2,属于那种事后会咬到运维的 fail-OPEN。和 issue #5704 以及已有的 maxPendingPromptsPerSession 整数校验模式一致。保留 0 / Infinity 作为无限制哨兵值也维持了向后兼容性。不需要 CHANGELOG 引用 —— 这是落在正确位置的一个窄 bug fix。

方案:范围紧凑且合适。两个文件、净 +4 行、没有顺手重构。校验顺序(isFiniteisInteger → 非负)读起来清晰,错误信息也和现有风格一致。Stage 2 再看一个小点:测试现在匹配 /maxSessions/ 而不是之前精确到值的正则,断言精度稍微退了一点 —— 值得看一眼原来的粒度是有意的还是顺手的。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

The validation in packages/acp-bridge/src/bridge.ts now rejects non-finite and non-integer maxSessions while preserving the existing undefined / 0 / Infinity sentinels. The shape mirrors the existing maxPendingPromptsPerSession validator a few lines below and matches the CLI-side guard in packages/cli/src/serve/run-qwen-serve.ts (isNonNegativeIntegerOrInfinity).

One small note: Number.isFinite is technically redundant because Number.isInteger already returns false for NaN/Infinity. The nearby eventRingSize comment even calls this out explicitly ("Number.isInteger already rejects NaN / Infinity / non-finite — no separate Number.isFinite guard needed"). Not a blocker — it reads clearly, matches the run-qwen-serve.ts idiom, and the redundancy is harmless.

The test refactor consolidates three invalid-value cases into it.each and adds a dedicated accepts disabled maxSessions sentinels test. The assertion match was relaxed from value-specific regex (/maxSessions: 1\.5/) to just /maxSessions/ — fine because the error prefix is distinctive, though value-specific matches would have been slightly stronger signal. Not worth a round-trip.

No critical correctness bugs, no security holes, no AGENTS.md violations. Diff is tight and focused on the stated goal.

Real-scenario testing

The bug lives in the library (acp-bridge), not in the CLI — the CLI already rejects fractional --max-sessions at the flag layer. So I drove the bridge directly.

Before / After — validation behavior

Ran a Node probe that mirrors the exact validation logic from packages/acp-bridge/src/bridge.ts against the same inputs. Source shown side-by-side, then probe output:

runner@runnervm7b5n9:~/work/qwen-code/qwen-code$ echo '=== BEFORE: source on main (HEAD) ===' && git show HEAD:packages/acp-bridge/src/bridge.ts | sed -n '710,735p'
=== BEFORE: source on main (HEAD) ===
  //                    guard is fail-OPEN behavior — we'd rather fail
  //                    boot than serve unbounded.
  let maxSessions: number;
  if (opts.maxSessions === undefined) {
    maxSessions = DEFAULT_MAX_SESSIONS;
  } else if (Number.isNaN(opts.maxSessions)) {
    throw new TypeError(
      `Invalid maxSessions: NaN. Must be a number >= 0 ` +
        `(0 / Infinity = unlimited).`,
    );
  } else if (opts.maxSessions < 0) {
    throw new TypeError(
      `Invalid maxSessions: ${opts.maxSessions}. Must be >= 0 ` +
        `(0 / Infinity = unlimited).`,
    );
  } else if (opts.maxSessions === 0 || opts.maxSessions === Infinity) {
    maxSessions = Infinity;
  } else {
    maxSessions = opts.maxSessions;
  }

runner@runnervm7b5n9:~/work/qwen-code/qwen-code$ echo '=== AFTER: source on PR branch (worktree) ===' && sed -n '710,735p' .qwen/worktrees/triage/packages/acp-bridge/src/bridge.ts
=== AFTER: source on PR branch (worktree) ===
  // `0` → explicitly unlimited (operator opt-out).
  // `Infinity` → unlimited (programmatic opt-out — accepted as a
  //              long-standing alias since the cap check is `>= max`).
  // non-integer / `NaN` / negative → throw. A typo / parse error in
  //                    CLI/config silently disabling or rounding the
  //                    daemon's only resource guard is fail-OPEN behavior —
  //                    we'd rather fail boot than serve with the wrong cap.
  let maxSessions: number;
  if (opts.maxSessions === undefined) {
    maxSessions = DEFAULT_MAX_SESSIONS;
  } else if (opts.maxSessions === 0 || opts.maxSessions === Infinity) {
    maxSessions = Infinity;
  } else if (
    !Number.isFinite(opts.maxSessions) ||
    !Number.isInteger(opts.maxSessions) ||
    opts.maxSessions < 0
  ) {
    throw new TypeError(
      `Invalid maxSessions: ${opts.maxSessions}. ` +
        `Must be a non-negative integer ` +
        `(0 / Infinity = unlimited).`,
    );
  } else {
    maxSessions = opts.maxSessions;
  }

runner@runnervm7b5n9:~/work/qwen-code/qwen-code$ echo '=== probe: same inputs against OLD vs NEW validation ===' && node /tmp/probe-maxsessions.js 2>&1 | tee tmp/$S/probe.log
=== probe: same inputs against OLD vs NEW validation ===
input                            | OLD (HEAD)     | NEW (PR #5705)
---------------------------------|----------------|----------------
undefined (default)              | 20             | 20
0 (unlimited sentinel)           | Infinity (unlimited) | Infinity (unlimited)
Infinity (unlimited sentinel)    | Infinity (unlimited) | Infinity (unlimited)
7 (valid integer)                | 7              | 7
NaN                              | THROWS: Invalid maxSessions: NaN | THROWS: Invalid maxSessions: NaN
-5 (negative)                    | THROWS: Invalid maxSessions: -5 | THROWS: Invalid maxSessions: -5
1.5 (fractional — the bug)       | 1.5            | THROWS: Invalid maxSessions: 1
2.9999 (fractional)              | 2.9999         | THROWS: Invalid maxSessions: 2

--- effective session cap check (current >= maxSessions) ---
maxSessions=1  →  cap=1  | session#1=deny, session#2=deny
maxSessions=1.5  →  cap=1.5  | session#1=allow, session#2=deny
maxSessions=2  →  cap=2  | session#1=allow, session#2=deny

The effective-cap table at the bottom shows the bug in action: maxSessions: 1.5 lets session #1 through but denies #2 — it behaves like 2, not 1. PR rejects it at construction, which is the right failure point.

Vitest — new tests fail on old code, pass on new code

runner@runnervm7b5n9:~/work/qwen-code/qwen-code/.qwen/worktrees/triage/packages/acp-bridge$ echo '=== vitest: NEW tests against OLD bridge.ts (should FAIL) ===' && /home/runner/work/qwen-code/qwen-code/node_modules/.bin/vitest run src/bridge.test.ts -t 'maxSessions' 2>&1 | tee /home/runner/work/qwen-code/qwen-code/tmp/$S/vitest-old.log
=== vitest: NEW tests against OLD bridge.ts (should FAIL) ===

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/bridge.test.ts > createAcpSessionBridge > opts validation > rejects invalid maxSessions (float)
AssertionError: expected [Function] to throw an error

- Expected:
null

+ Received:
undefined

 ❯ src/bridge.test.ts:4703:56
    4701|       // NOT silently disable the daemon's resource cap. We fail
    4702|       // boot loud instead of serving unbounded.
    4703|       expect(() => makeBridge({ maxSessions: value })).toThrow(/maxSes…
       |                                                        ^

 Test Files  1 failed (1)
      Tests  1 failed | 16 passed | 282 skipped (299)

runner@runnervm7b5n9:~/work/qwen-code/qwen-code/.qwen/worktrees/triage/packages/acp-bridge$ echo '=== vitest: NEW tests against NEW bridge.ts (should PASS) ===' && /home/runner/work/qwen-code/qwen-code/node_modules/.bin/vitest run src/bridge.test.ts -t 'maxSessions' 2>&1 | tee /home/runner/work/qwen-code/qwen-code/tmp/$S/vitest-new.log
=== vitest: NEW tests against NEW bridge.ts (should PASS) ===
 ✓ src/bridge.test.ts (299 tests | 282 skipped) 54ms

 Test Files  1 passed (1)
      Tests  17 passed | 282 skipped (299)

The rejects invalid maxSessions (float) case is exactly the bug from issue #5704 — the new test catches it on old code, confirms the fix on new code.

Vitest — full bridge.test.ts regression run on PR branch

runner@runnervm7b5n9:~/work/qwen-code/qwen-code/.qwen/worktrees/triage/packages/acp-bridge$ /home/runner/work/qwen-code/qwen-code/node_modules/.bin/vitest run src/bridge.test.ts
 ✓ src/bridge.test.ts (299 tests) 2851ms

 Test Files  1 passed (1)
      Tests  299 passed (299)

All 299 bridge tests pass — no regressions in session cap, reaper, channel teardown, mid-turn message queue, or the other createAcpSessionBridge validation suites.

Verdict

The change is tight, correct, and well-tested. Two minor non-blocking notes (redundant Number.isFinite, relaxed test regex) — neither affects behavior or maintainability.

中文说明

代码审查

packages/acp-bridge/src/bridge.ts 中的校验现在会拒绝非有限和非整数的 maxSessions,同时保留原有的 undefined / 0 / Infinity 哨兵值。形态和下面几行的 maxPendingPromptsPerSession 校验一致,也和 packages/cli/src/serve/run-qwen-serve.ts 中 CLI 层的 isNonNegativeIntegerOrInfinity 守卫一致。

一个小点:Number.isFinite 其实是多余的,因为 Number.isInteger 对 NaN/Infinity 就已经返回 false。附近的 eventRingSize 注释还专门提到过("Number.isInteger already rejects NaN / Infinity / non-finite — no separate Number.isFinite guard needed")。不算 blocker —— 读起来清晰、和 run-qwen-serve.ts 的习惯一致,多余的检查也无害。

测试重构把三个非法值用例合并成 it.each,并新增了一个专门的 accepts disabled maxSessions sentinels 测试。断言匹配从精确到值的正则(/maxSessions: 1\.5/)放松到了 /maxSessions/ —— 因为错误前缀是独特的所以没问题,虽然保留值级匹配信号更强一点。不值得为这个来回一轮。

没有关键的正确性 bug、没有安全漏洞、没有违反 AGENTS.md。Diff 紧凑,聚焦在声明的目标上。

真实场景测试

Bug 在库(acp-bridge)里,不在 CLI —— CLI 在参数层就已经拒绝了小数 --max-sessions。所以我直接驱动 bridge 测。

前/后 —— 校验行为

跑了一个 Node probe,把 packages/acp-bridge/src/bridge.ts 的校验逻辑原样复刻,用同一组输入对比。源码并排展示,然后是 probe 输出:

input                            | OLD (HEAD)     | NEW (PR #5705)
---------------------------------|----------------|----------------
undefined (default)              | 20             | 20
0 (unlimited sentinel)           | Infinity (unlimited) | Infinity (unlimited)
Infinity (unlimited sentinel)    | Infinity (unlimited) | Infinity (unlimited)
7 (valid integer)                | 7              | 7
NaN                              | THROWS | THROWS
-5 (negative)                    | THROWS | THROWS
1.5 (fractional — the bug)       | 1.5    | THROWS
2.9999 (fractional)              | 2.9999 | THROWS

--- effective session cap check (current >= maxSessions) ---
maxSessions=1    →  cap=1    | session#1=deny, session#2=deny
maxSessions=1.5  →  cap=1.5  | session#1=allow, session#2=deny
maxSessions=2    →  cap=2    | session#1=allow, session#2=deny

底部的有效上限表清楚地展示了 bug:maxSessions: 1.5 让 session #1 通过、拒绝 #2 —— 表现得像 2 而不是 1。PR 在构造期就拒绝,是正确的失败点。

Vitest —— 新测试在旧代码上失败、在新代码上通过

=== vitest: NEW tests against OLD bridge.ts (should FAIL) ===
FAIL  src/bridge.test.ts > createAcpSessionBridge > opts validation > rejects invalid maxSessions (float)
AssertionError: expected [Function] to throw an error
Test Files  1 failed (1)
     Tests  1 failed | 16 passed | 282 skipped (299)

=== vitest: NEW tests against NEW bridge.ts (should PASS) ===
✓ src/bridge.test.ts (299 tests | 282 skipped) 54ms
Test Files  1 passed (1)
     Tests  17 passed | 282 skipped (299)

rejects invalid maxSessions (float) 用例正好就是 issue #5704 的 bug —— 新测试在旧代码上捕获到了,在新代码上确认修复。

Vitest —— PR 分支完整 bridge.test.ts 回归

✓ src/bridge.test.ts (299 tests) 2851ms
Test Files  1 passed (1)
     Tests  299 passed (299)

全部 299 个 bridge 测试通过 —— session cap、reaper、channel teardown、mid-turn 消息队列、其它 createAcpSessionBridge 校验套件都没有回归。

结论

改动紧凑、正确、测试充分。两个小的非阻塞备注(多余的 Number.isFinite、放松的测试正则)—— 都不影响行为或可维护性。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Stepping back: this is a small, well-motivated bug fix that does exactly what issue #5704 asks for and nothing more. The validation ladder (isFiniteisInteger → non-negative) reads cleanly, the error message preserves the existing style, and the test coverage is meaningful — the new (float) test case actually catches the bug on the old code, which is the whole point of a regression test.

The before/after evidence is unambiguous: maxSessions: 1.5 used to slip through and silently behave like 2, now it throws at bridge construction. The 299-test bridge suite passes clean, so there's no regression in session cap, reaper, channel teardown, or the other validation suites.

My independent proposal would have been essentially the same shape (consolidate the invalid branches, add Number.isInteger), so I don't see a materially simpler path the PR missed. The one nit I flagged in Stage 2 — Number.isFinite being redundant given Number.isInteger — is genuinely minor and matches an existing codebase idiom. Not worth a round-trip.

Approving. Ships the fix cleanly, tests catch the exact bug, no scope creep. 👍

中文说明

退一步看:这是一个小而动机明确的 bug fix,完全按照 issue #5704 的要求做,没有多余的东西。校验顺序(isFiniteisInteger → 非负)读起来清晰,错误信息保留了现有风格,测试覆盖也有意义 —— 新的 (float) 用例在旧代码上确实能抓到 bug,这正是回归测试的全部意义。

前/后证据很明确:maxSessions: 1.5 以前会漏过去、静默表现得像 2,现在会在 bridge 构造期直接抛错。299 个 bridge 测试全部通过,session cap、reaper、channel teardown 以及其它校验套件都没有回归。

我自己独立给出的方案基本也是这个形状(合并非法分支、加 Number.isInteger),所以没看到 PR 漏掉了更简单的路径。Stage 2 提的那个小点 —— Number.isFinite 在有 Number.isInteger 的情况下多余 —— 真的是小事,也和代码库里已有的习惯一致。不值得为这个来回一轮。

通过。修复干净、测试抓到了确切的 bug、没有范围蔓延。👍

Qwen Code · qwen3.7-max

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

@pomelo-nwu

Copy link
Copy Markdown
Collaborator

maxSessions is a count-based cap compared as current >= maxSessions. Since current is always an integer (you cannot have 0.5 active sessions), current >= 1.5 is behaviorally identical to current >= 2. There is no observable difference — no off-by-one, no resource leak, no crash.

The PR says fractional values "silently change the effective limit" — but the effective limit for maxSessions: 1.5 is 2 sessions, which is exactly what any reasonable user would expect from a fractional input.

Feel free to reopen with a concrete reproduction. For future PRs, please demonstrate the actual failure rather than "this value could theoretically be passed."

中文说明

maxSessions 是基于计数的上限,比较方式为 current >= maxSessions。由于 current 始终是整数(不可能有 0.5 个活跃会话),current >= 1.5 在行为上等价于 current >= 2。没有可观测差异——没有 off-by-one、没有资源泄漏、没有崩溃。

PR 说小数值"silently change the effective limit"——但 maxSessions: 1.5 的有效上限就是 2 个会话,完全符合对小数输入的合理预期。

欢迎带上具体复现案例重新打开。后续 PR 请展示实际失败场景。

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.

bug(serve): maxSessions accepts fractional limits

4 participants