Skip to content

fix(cli): resolve stale closure race in text buffer submit handler#4470

Merged
pomelo-nwu merged 1 commit into
QwenLM:mainfrom
huww98:fix/text-buffer-stale-closure
May 25, 2026
Merged

fix(cli): resolve stale closure race in text buffer submit handler#4470
pomelo-nwu merged 1 commit into
QwenLM:mainfrom
huww98:fix/text-buffer-stale-closure

Conversation

@huww98

@huww98 huww98 commented May 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • What changed: Replaced useReducer with useRef + useState + synchronous dispatch in useTextBuffer, and exposed text, lines, cursor as getters on the returned TextBuffer object.
  • Why it changed: When input arrives rapidly (e.g., tmux send-keys "text" Enter), characters and Enter arrive in the same event loop tick. React's useReducer dispatch only enqueues actions — the reducer runs later during render. The Enter handler reads stale buffer.text from the previous render closure (empty string), so the message is never submitted.
  • Reviewer focus: The core change in useTextBuffer (lines 1938-1944) and the getter pattern on the returned object (lines 2483-2493).

Validation

  • Commands run:
    # Broken (upstream/main):
    tmux new-session -d -s broken-test -x 200 -y 50 "npm run dev -- --approval-mode yolo"
    # wait for ready...
    tmux send-keys -t broken-test "hello world" Enter
    # Result: text stuck in input box, NOT submitted
    
    # Fixed (this branch):
    tmux new-session -d -s fixed-test -x 200 -y 50 "npm run dev -- --approval-mode yolo"
    # wait for ready...
    tmux send-keys -t fixed-test "hello world" Enter
    # Result: message submitted immediately, LLM responded
  • Prompts / inputs used: tmux send-keys "hello world" Enter (no sleep between text and Enter)
  • Expected result: Message submits and LLM responds
  • Observed result: Before fix — text stays in input box. After fix — message submits correctly.
  • Quickest reviewer verification path: npm run dev, then in another terminal: tmux send-keys -t <session> "test message" Enter — verify it submits without needing a sleep.
  • Note: Claude Code (v2.1.146) does NOT have this bug — the same tmux send-keys "text" Enter pattern works correctly there, indicating their text input implementation handles this differently.

Alternatives Considered

1. Dispatch a submit action (keep useReducer, handle submit inside the reducer)

The reducer processes actions sequentially, so by the time it handles a submit action, all preceding insert actions are already applied. Fire onSubmit via a useEffect watching a pendingSubmit state field.

Rejected because:

  • useEffect is async (fires after render commit) — introduces coordination complexity.
  • If two submits batch into the same render, the first pendingSubmit value is overwritten before the effect fires, losing a message. Requires a pendingSubmits: string[] array with careful clear_processed(count) logic.
  • Fundamentally fragile: dispatches between render-commit and effect-fire can interleave with the clear action in unexpected ways.

2. Mutate a ref inside the reducer

Store a useRef and write to it as a side effect inside the reducer function itself, so it's "up to date" after each action.

Rejected because:

  • The reducer does NOT run during dispatch(). It runs during the render phase (updateReducerImpl). Writing to a ref inside the reducer provides no advantage over reading state — both are only available after React renders. Confirmed by reading React reconciler source (dispatchReducerAction only enqueues; updateReducerImpl drains the queue during render).
  • Also makes the reducer impure.

3. flushSync in the keypress handler

Force React to synchronously process all pending state updates before the Enter handler reads buffer.text.

Rejected because:

  • Heavy-handed: forces a synchronous re-render mid-event-handler, defeating React's batching optimization.
  • Unclear interaction with Ink's custom reconciler.

4. Chosen: useRef + useState + synchronous manual dispatch

Call the reducer ourselves at dispatch time, store result in a useRef (synchronous reads), call setState (trigger renders). Expose text/lines/cursor as getters reading from stateRef.current.

Why this wins:

  • dispatch is stable (useCallback(fn, []) — reads from ref, not closure).
  • All existing useCallback wrappers remain unchanged (dispatch is stable just like useReducer's was).
  • Zero downstream consumer changes — getters are transparent to the TypeScript type system.
  • No coordination problems, no lost messages, no async effects needed for the submit path.
  • React's bailout optimization still works (setState skips render if same reference).

Scope / Risk

  • Main risk or tradeoff: Two copies of state in memory (ref + useState) — negligible cost. All 28+ useCallback wrappers now list dispatch in deps (it's stable, so no behavior change).
  • Not covered / not validated: Windows, Docker, Podman environments. The fix is pure React logic with no platform-specific behavior.
  • Breaking changes / migration notes: None. The TextBuffer interface is unchanged — getters satisfy the same type as plain properties.

Testing Matrix

🍏 🪟 🐧
npm run ⚠️ ⚠️
npx ⚠️ ⚠️ ⚠️
Docker ⚠️ ⚠️ ⚠️
Podman ⚠️ N/A N/A
Seatbelt ⚠️ N/A N/A

Testing matrix notes:

  • macOS npm run: full test suite (168 text-buffer tests, 163 vim tests, 156 InputPrompt tests pass), manual tmux verification
  • Other platforms: same React logic, no platform-specific code paths

Linked Issues / Bugs

No linked issues.


🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR fixes a critical race condition in the text input handling where rapid input (e.g., tmux send-keys "text" Enter) causes the Enter handler to read stale buffer.text from a previous render closure, preventing message submission. The fix replaces useReducer with a synchronous dispatch pattern using useRef + useState, ensuring immediate state reads. The implementation is technically sound and well-executed, with minimal changes to the public API through the use of getters.

🔍 General Feedback

  • Strong technical analysis: The PR description thoroughly documents the root cause (React's useReducer dispatch enqueues actions for the next render, not immediate execution) and systematically evaluates alternative solutions with clear rejection criteria.
  • Minimal API surface changes: Using getters on the returned TextBuffer object is an elegant solution that avoids breaking changes to consumers (BaseTextInput, InputPrompt, vim hook).
  • Consistent dependency updates: All useCallback hooks now correctly include dispatch in their dependency arrays, which is the right pattern for this architecture.
  • Good validation approach: The tmux-based reproduction and verification steps are practical and easy for reviewers to execute.

🎯 Specific Feedback

🔴 Critical

No critical issues identified. The core fix addresses a critical bug correctly.

🟡 High

File: packages/cli/src/ui/components/shared/text-buffer.ts:1938-1944 - Potential race condition in concurrent React rendering

The synchronous dispatch pattern (stateRef.current = textBufferReducer(stateRef.current, action)) assumes serial execution. In React's concurrent rendering mode (even if not explicitly enabled), there's a theoretical risk that multiple dispatches in the same event tick could interleave if React suspends or interrupts rendering.

Recommendation: Add a comment documenting why this is safe in the current context (Ink's reconciler behavior, or if concurrent features are disabled), or consider adding a simple assertion/guard if this becomes a concern in the future.

🟢 Medium

File: packages/cli/src/ui/components/shared/text-buffer.ts:1948-1953 - Destructuring from state may become stale

The code destructures lines, cursorRow, cursorCol, etc. from state (line 1948-1953), but these values are only used for deriving text, visualCursor, and passing to useMemo dependencies. Since state is updated via setState, there's a render cycle where destructured values and stateRef.current could theoretically diverge.

Recommendation: Consider reading directly from stateRef.current for consistency, or add a comment explaining why reading from state is safe here (the setState(stateRef.current) call ensures they're synchronized before the next render).

File: packages/cli/src/ui/components/shared/text-buffer.ts:2367 - Reading from stateRef.current in external editor

The external editor function reads stateRef.current.lines.join('\n') to get the current text before writing to the temp file. This is correct, but note that this is one of the few places that reads from the ref directly rather than through the getters.

Recommendation: This is acceptable since it's inside a useCallback that doesn't have access to the returnValue object. Consider adding a brief comment explaining why direct ref access is necessary here.

🔵 Low

File: packages/cli/src/ui/components/shared/text-buffer.ts:1938 - Import cleanup

The import on line 11 now uses useRef instead of useReducer. Consider verifying that useReducer is not used elsewhere in the file (a quick grep confirms it's not).

File: packages/cli/src/ui/components/shared/text-buffer.ts:2540-2550 - Getter pattern documentation

The getters for lines, text, and cursor are a clever pattern, but future maintainers might not immediately understand why these are getters rather than plain properties.

Recommendation: Add a brief JSDoc comment explaining the pattern:

// Getters provide synchronous access to the latest state from stateRef.current,
// avoiding stale closure issues that caused the original bug.
get lines() {
  return stateRef.current.lines;
},

File: packages/cli/src/ui/components/shared/text-buffer.ts:2610+ - useMemo dependency list

The dependency array for the returnValue useMemo no longer includes lines, text, cursorRow, cursorCol (as shown in the diff). This is correct since getters don't need these as dependencies. Consider adding a comment explaining this intentional omission to prevent future developers from "fixing" it.

✅ Highlights

  • Excellent bug investigation: The root cause analysis is thorough, including verification that Claude Code (v2.1.146) doesn't have this bug, which provides a useful benchmark.
  • Well-documented alternatives: The "Alternatives Considered" section is exemplary—each rejected approach includes specific technical reasons (e.g., "useEffect is async", "reducer does NOT run during dispatch()", confirmed by reading React reconciler source).
  • Clean getter abstraction: The getter pattern on the returned object is an elegant solution that provides synchronous reads without requiring changes to consumer code.
  • Comprehensive dependency updates: All affected callbacks were systematically updated to include dispatch in their dependencies, showing attention to detail.
  • Practical validation: The tmux-based test is reproducible and demonstrates the bug clearly ("text stuck in input box" vs. "message submitted immediately").

Replace useReducer with useRef + useState + synchronous dispatch so that
event handlers always read the latest buffer state. Previously, rapid
input via tmux send-keys could deliver characters and Enter in the same
event loop tick; the Enter handler read buffer.text from a stale render
closure (empty string) because useReducer's dispatch only enqueues
actions for the next render pass.

The fix runs the reducer synchronously at dispatch time, stores results
in a useRef for immediate reads, and calls setState to trigger
re-renders. The returned TextBuffer object exposes text, lines, and
cursor as getters reading from stateRef.current, so all consumers
(BaseTextInput, InputPrompt, vim hook) automatically get fresh values
without code changes.
@huww98 huww98 force-pushed the fix/text-buffer-stale-closure branch from 40cbefe to e5d0559 Compare May 23, 2026 18:54
@huww98

huww98 commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

Potential race condition in concurrent React rendering

dispatch and stateRef.current = textBufferReducer(stateRef.current, action) runs synchronously. And node.js is single-threaded. I think there is no way this could break, regardless of how react implements.

@wenshao wenshao 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 May 24, 2026

Copy link
Copy Markdown
Collaborator

PR 4470 本地 tmux 验证报告

PR: #4470 fix(cli): resolve stale closure race in text buffer submit handler
作者: @huww98
Base: 4dc98484f(merge-base on main)
Tip: e5d0559eb
验证时间: 2026-05-24
验证环境: macOS Darwin 25.4.0 / tmux 会话 pr4470(6 个验证窗口)+ pr4470-tui-app(TUI 真实交互测试)


1. 总体结论

维度 结果
Bug 修复(TUI 真实测试) 3/3 tmux send-keys 测试全部成功提交
构建 / typecheck ✅ 全过
Lint ✅ 改动的单文件零 warning
text-buffer 单元测试 ✅ 320 passed, 1 skipped, 0 failed
TUI 竞态压力测试 ✅ 短/中/长文本均正确提交,无截断

合并建议:✅ 可以合并。精准修复 useReducer 闭包陈旧竞态,单元测试 + 真实 TUI 双重验证通过。


2. 验证矩阵

Window 用途 结果
0 install npm ci EXIT=0
1 build npm run build EXIT=0
2 typecheck npm run typecheck(5 包) EXIT=0
3 test-unit text-buffer + vim + InputPrompt + SuggestionsDisplay 320 passed, 4 files
4 lint eslint on text-buffer.ts EXIT=0(0 warning)
5 tui-test 真实 TUI tmux send-keys 竞态验证 3/3 ✓

3. 根因与修复

3.1 问题

tmux send-keys "text" Enter 在同一个 event loop tick 发送字符和 Enter:

tick 1: insert('t'), insert('e'), insert('x'), insert('t')  → React enqueues 4 actions
tick 1: Enter keypress → handler reads buffer.text from stale closure
                         → buffer.text === '' (reducer hasn't run yet!)
                         → message NEVER submitted ❌

useReducerdispatch 只将 action 入队,reducer 在下次 render 阶段才执行。Enter 处理器读取的是前一次 render closure 中的 state.text(仍为空字符串)。

3.2 修复

useRef + useState + 同步 dispatch 替换 useReducer

// Before: useReducer — dispatch 仅入队,状态异步更新
const [state, dispatch] = useReducer(textBufferReducer, initialState);

// After: useRef + 同步 dispatch — 状态在 dispatch 时立即可读
const stateRef = useRef(initialState);
const [state, setState] = useState(initialState);

const dispatch = useCallback((action: TextBufferAction) => {
  stateRef.current = textBufferReducer(stateRef.current, action);
  setState(stateRef.current);
}, []);

返回的 TextBuffer 对象将 textlinescursor 暴露为 getter,读取 stateRef.current

return {
  get text() { return stateRef.current.text; },
  get lines() { return stateRef.current.lines; },
  get cursor() { return stateRef.current.cursor; },
  ...
};

关键设计

  1. dispatchuseCallback(fn, []) — 稳定引用,所有下游 useCallback 安全地将其加入 deps
  2. getter 模式对 TypeScript 类型透明 — 下游无需任何代码修改
  3. 两份状态(ref + state)内存开销可忽略;React bailout 优化仍然生效(相同引用跳过 render)

3.3 替代方案对比

PR 作者在 Alternatives Considered 中评估了 4 种方案:

方案 问题
dispatch submit action 到 reducer useEffect 异步,两条 submit 可能互相覆盖丢失消息
reducer 内部写 ref reducer 在 render 阶段执行,dispatch 时仍不可用
flushSync 强制同步渲染 重量级,绕过 React batching,与 Ink 自定义 reconciler 交互未知
useRef + 同步 dispatch 零额外协调、零丢失消息、零下游改动

4. TUI 真实竞态测试

4.1 测试方法

1. tmux new-session -d -s pr4470-tui-app -x 200 -y 50 \
     "node dist/cli.js --approval-mode yolo"
2. Wait for input prompt ("Type your message or @path/to/file")
3. tmux send-keys -t pr4470-tui-app "<message>" Enter
4. Observe whether text disappears from input box (submitted) or stays (bug)

4.2 测试结果

# 输入 结果 LLM 响应
1 hello from tmux sendkeys test 4470 ✅ 提交 "收到测试消息"
2 rapid test 2(快速连续) ✅ 提交 "rapid test 2 已接收。消息投递正常"
3 长文本(~70 chars,含完整句子) ✅ 提交,无截断 "确认收到,长文本 rapid fire 测试通过。消息完整,未截断"

测试覆盖

  • ✅ 短文本 + Enter 同 tick → 提交
  • ✅ 快速连续两次 send-keys → 都提交
  • ✅ 长文本 → 完整保留,无字符丢失
  • ✅ 每次 LLM 都正确回复,证明消息被提交到对话历史

4.3 如果 PR 未合并,同样测试会怎样

根据 PR 作者描述和 useReducer 语义,旧代码执行同一测试时:

  • 输入框中的文字不会消失(buffer.text 读到的仍是空字符串)
  • Enter 处理器看到空文本,跳过提交
  • 用户需要手动再按一次 Enter 才能提交

5. 单元测试覆盖

Test Files  4 passed (4)
Tests       320 passed | 1 skipped (321)
Duration    52.31s

覆盖:
  text-buffer.test.ts                   168 tests
  text-buffer-external-editor.test.ts   ...
  vim.test.ts                           163 tests
  InputPrompt.test.tsx                  156 tests
  SuggestionsDisplay.test.tsx           ...

零失败。所有 useCallback deps 更新(添加 dispatch)未引入行为变更 — dispatch 本身就是稳定引用。


6. 代码审查要点

6.1 dispatch 稳定性

const dispatch = useCallback((action: TextBufferAction) => {
  stateRef.current = textBufferReducer(stateRef.current, action);
  setState(stateRef.current);
}, []);

空 deps — dispatch 在组件生命周期内不变,与 useReducer 的 dispatch 有相同的稳定性保证。

6.2 getter 模式

get text(): string { return stateRef.current.text; },
get lines(): string[] { return stateRef.current.lines; },
get cursor(): { ... } { return stateRef.current.cursor; },

对于 TextBuffer 的使用者(BaseTextInputInputPrompt、vim hook),. 访问语法不变 — buffer.text 在改动前后都返回最新文本,只是现在从 stateRef.current 读取而非从 React state 闭包。

6.3 useCallback deps 正确性

28+ 个 useCallback 的 deps 从 [] 变为 [dispatch]。由于 dispatch 是稳定的(useCallback(fn, [])),这些 callback 仍然只在挂载时创建一次。ESLint react-hooks/exhaustive-deps 规则满足。


7. 合并建议

建议合并

单文件改动(+172/-99),精准解决 useReducer 异步 dispatch 导致的竞态条件。单元测试 320 通过 + 真实 TUI 3 场景验证 + 长文本压力测试全部通过。替代方案已充分评估并驳回。


8. 复现指引

# 进入验证 tmux 会话
tmux attach -t pr4470

# 验证环境
cd /tmp/pr4470-test

# 运行单元测试
cd packages/cli && npx vitest run --no-coverage \
  src/ui/components/shared/text-buffer.test.ts \
  src/ui/components/shared/text-buffer-external-editor.test.ts \
  src/ui/hooks/vim/vim.test.ts \
  src/ui/components/InputPrompt.test.tsx

# 真实 TUI 竞态测试
tmux new-session -d -s tui-test -x 200 -y 50 \
  "node /tmp/pr4470-test/dist/cli.js --approval-mode yolo"
# 等待 "Type your message" 提示出现
tmux send-keys -t tui-test "test message" Enter
# 观察消息是否从输入框消失 = 提交成功

报告由 Claude Opus 4.7 在本地 tmux 上完整验证(含真实 TUI 竞态测试),作为维护者 merge 决策参考。

@pomelo-nwu pomelo-nwu 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

@pomelo-nwu pomelo-nwu merged commit ab26a5a into QwenLM:main May 25, 2026
10 checks passed
doudouOUC added a commit that referenced this pull request May 25, 2026
…4500)

Pulls 5 main commits since #4469 (2026-05-24):
- #4464 fix(weixin): send decryptable image payloads
- #4465 fix(weixin): allow Windows image paths inside workspace
- #4470 fix(cli): resolve stale closure race in text buffer submit handler
- #4468 feat(skills): add memory-leak-debug skill for heap snapshot diagnosis
- #4288 feat(cli): do not append trailing space for directory completions (#4092)

11 manual conflicts resolved + 2 add/add conflicts taken from main wholesale:

Manual UU (12, all daemon-side preferred except text-buffer.ts):
- packages/acp-bridge/package.json — kept HEAD's fuller description (F1 lift expanded the package surface; main has stale pre-F1 wording).
- packages/cli/src/acp-integration/acpAgent.ts — kept HEAD's WorkspaceMcpBudget import (F2 needs it).
- packages/cli/src/acp-integration/acpAgent.worktree.test.ts (AA): kept HEAD's superset of mocks
  (MCP_BUDGET_WARN_FRACTION, getMCPDiscoveryState, MCPServerStatus, McpTransportPool, WorkspaceMcpBudget, workspace/debug/mcp config mocks). HEAD already includes
  main-side SessionStartSource + SessionEndReason mocks.
- packages/cli/src/ui/commands/directoryCommand.tsx — pure formatting (HEAD wrapped vs main inline). Kept HEAD.
- packages/cli/src/ui/commands/directoryCommand.test.tsx — pure formatting. Kept HEAD.
- packages/cli/src/ui/commands/skillsCommand.ts — pure formatting. Kept HEAD.
- packages/cli/src/ui/hooks/useCommandCompletion.tsx — pure formatting. Kept HEAD.
- packages/cli/src/ui/hooks/useCommandCompletion.test.ts — pure formatting. Kept HEAD.
- packages/cli/src/ui/hooks/useSlashCompletion.test.ts — pure formatting. Kept HEAD.
- packages/core/src/config/config.test.ts — kept HEAD's TrustGateError import (daemon-added).

text-buffer.ts (4 zones — took MAIN wholesale for #4470's stale-closure fix):
- Import: useRef instead of useReducer (daemon side had useReducer as a dead import — file uses dispatch via useCallback, not useReducer; verified via grep). useRef is needed for stateRef + #4470's currentText capture.
- writeFileSync zone: use stateRef.current.lines.join('\n') instead of stale closure-captured `text`. Fixes #4470's bug.
- text comparison: `newText !== currentText` not `newText !== text`.
- dep array: `[dispatch, ...]` not `[text, ...]` (callback reads from ref now, doesn't need to re-bind on text change).

AA (2, main wholesale via git checkout --theirs):
- packages/core/src/permissions/dangerousRules.ts + .test.ts
  Original #4151 Auto-mode added these on main, came into daemon via #4469 squash.
  Main then landed #4371 ("strip additional dangerous interpreter rules") as a follow-up
  that daemon side never saw. Take main's evolved version wholesale.

Verification:
- packages/core tsc: 50 errors PRE-merge, 50 errors POST-merge (pre-existing baseline — none introduced by this sync).
- packages/acp-bridge tsc: clean.
- 5 spot-test runs on conflict-resolved files: 132 + 17 + 24 + 30 + 1 = 204 tests pass (text-buffer / directoryCommand / useCommandCompletion / useSlashCompletion / skillsCommand).

Mirrors #4469's pattern (squash merge daemon_mode_b_main-side). Unblocks
#4490 daemon_mode_b_main → main reverse integration merge (currently
CONFLICTING precisely because of these 5 main commits).
xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
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