fix(cli): resolve stale closure race in text buffer submit handler#4470
Conversation
📋 Review SummaryThis PR fixes a critical race condition in the text input handling where rapid input (e.g., 🔍 General Feedback
🎯 Specific Feedback🔴 CriticalNo critical issues identified. The core fix addresses a critical bug correctly. 🟡 HighFile: The synchronous dispatch pattern ( 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. 🟢 MediumFile: The code destructures Recommendation: Consider reading directly from File: The external editor function reads Recommendation: This is acceptable since it's inside a 🔵 LowFile: The import on line 11 now uses File: The getters for 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: The dependency array for the ✅ Highlights
|
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.
40cbefe to
e5d0559
Compare
|
wenshao
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review
PR 4470 本地 tmux 验证报告PR: #4470 fix(cli): resolve stale closure race in text buffer submit handler 1. 总体结论
合并建议:✅ 可以合并。精准修复 2. 验证矩阵
3. 根因与修复3.1 问题当
3.2 修复用 // 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);
}, []);返回的 return {
get text() { return stateRef.current.text; },
get lines() { return stateRef.current.lines; },
get cursor() { return stateRef.current.cursor; },
...
};关键设计:
3.3 替代方案对比PR 作者在 Alternatives Considered 中评估了 4 种方案:
4. TUI 真实竞态测试4.1 测试方法4.2 测试结果
测试覆盖:
4.3 如果 PR 未合并,同样测试会怎样根据 PR 作者描述和
5. 单元测试覆盖零失败。所有 6. 代码审查要点6.1
|
…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).
Summary
useReducerwithuseRef+useState+ synchronous dispatch inuseTextBuffer, and exposedtext,lines,cursoras getters on the returnedTextBufferobject.tmux send-keys "text" Enter), characters and Enter arrive in the same event loop tick. React'suseReducerdispatch only enqueues actions — the reducer runs later during render. The Enter handler reads stalebuffer.textfrom the previous render closure (empty string), so the message is never submitted.useTextBuffer(lines 1938-1944) and the getter pattern on the returned object (lines 2483-2493).Validation
tmux send-keys "hello world" Enter(no sleep between text and Enter)npm run dev, then in another terminal:tmux send-keys -t <session> "test message" Enter— verify it submits without needing a sleep.tmux send-keys "text" Enterpattern works correctly there, indicating their text input implementation handles this differently.Alternatives Considered
1. Dispatch a
submitaction (keep useReducer, handle submit inside the reducer)The reducer processes actions sequentially, so by the time it handles a
submitaction, all precedinginsertactions are already applied. FireonSubmitvia auseEffectwatching apendingSubmitstate field.Rejected because:
useEffectis async (fires after render commit) — introduces coordination complexity.pendingSubmitvalue is overwritten before the effect fires, losing a message. Requires apendingSubmits: string[]array with carefulclear_processed(count)logic.2. Mutate a ref inside the reducer
Store a
useRefand write to it as a side effect inside the reducer function itself, so it's "up to date" after each action.Rejected because:
dispatch(). It runs during the render phase (updateReducerImpl). Writing to a ref inside the reducer provides no advantage over readingstate— both are only available after React renders. Confirmed by reading React reconciler source (dispatchReducerActiononly enqueues;updateReducerImpldrains the queue during render).3.
flushSyncin the keypress handlerForce React to synchronously process all pending state updates before the Enter handler reads
buffer.text.Rejected because:
4. Chosen:
useRef+useState+ synchronous manual dispatchCall the reducer ourselves at dispatch time, store result in a
useRef(synchronous reads), callsetState(trigger renders). Exposetext/lines/cursoras getters reading fromstateRef.current.Why this wins:
dispatchis stable (useCallback(fn, [])— reads from ref, not closure).useCallbackwrappers remain unchanged (dispatch is stable just like useReducer's was).Scope / Risk
useCallbackwrappers now listdispatchin deps (it's stable, so no behavior change).TextBufferinterface is unchanged — getters satisfy the same type as plain properties.Testing Matrix
Testing matrix notes:
Linked Issues / Bugs
No linked issues.
🤖 Generated with Claude Code