Skip to content

Commit 4a11412

Browse files
brandonkachenCodex CLIClaude Code CLIGemini CLI
committed
fix(cli): improve message queue atomicity and add --wait-idle to tmux harness
Defensive improvements to message queue handling: - chat.tsx: Use useLayoutEffect for synchronous store updates - use-message-queue.ts: Make queue operations atomic with functional setState Test harness fix for rapid message testing: - tmux-send.sh: Add --wait-idle SECS option that polls until terminal output stabilizes before returning. This allows rapid message tests to wait for streaming to complete between sends. Note: The 'rapid message scrolling' issue was primarily a test harness limitation where tmux sends input faster than the CLI can process during heavy rendering. The --wait-idle flag is the proper fix for this test scenario. Co-authored-by: Codex CLI <codex@openai.com> Co-authored-by: Claude Code CLI <claude@anthropic.com> Co-authored-by: Gemini CLI <gemini@google.com>
1 parent eb5e637 commit 4a11412

File tree

4 files changed

+123
-27
lines changed

4 files changed

+123
-27
lines changed

cli/src/chat.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query'
55
import {
66
useCallback,
77
useEffect,
8+
useLayoutEffect,
89
useMemo,
910
useRef,
1011
useState,
@@ -1372,8 +1373,9 @@ export const Chat = ({
13721373
(state) => state.setCallbacks,
13731374
)
13741375

1375-
// Update context when values change
1376-
useEffect(() => {
1376+
// Update context when values change - useLayoutEffect ensures synchronous updates
1377+
// to prevent message loss during rapid streaming (race condition fix)
1378+
useLayoutEffect(() => {
13771379
setMessageBlockContext({
13781380
theme,
13791381
markdownPalette,

cli/src/hooks/use-message-queue.ts

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,8 @@ export const useMessageQueue = (
2828
const isQueuePausedRef = useRef<boolean>(false)
2929
const isProcessingQueueRef = useRef<boolean>(false)
3030

31-
useEffect(() => {
32-
queuedMessagesRef.current = queuedMessages
33-
}, [queuedMessages])
31+
// Note: queuedMessagesRef is now updated atomically inside functional setState calls
32+
// (in addToQueue and the queue processing effect), so no sync effect is needed here.
3433

3534
useEffect(() => {
3635
isQueuePausedRef.current = queuePaused
@@ -114,24 +113,45 @@ export const useMessageQueue = (
114113

115114
isProcessingQueueRef.current = true
116115

117-
const nextMessage = queuedList[0]
118-
const remainingMessages = queuedList.slice(1)
119-
queuedMessagesRef.current = remainingMessages
120-
setQueuedMessages(remainingMessages)
121-
// Add .catch() to prevent unhandled promise rejections.
122-
// Safety net: release lock here in case sendMessage failed before its own error handling.
123-
// Lock is also released in finalizeQueueState and sendMessage's finally block (idempotent).
124-
sendMessage(nextMessage).catch((err: unknown) => {
125-
logger.warn(
126-
{ error: err },
127-
'[message-queue] sendMessage promise rejected - releasing lock',
128-
)
129-
isProcessingQueueRef.current = false
116+
// IMPORTANT: We must read the message to process INSIDE the functional setState
117+
// to ensure we send the same message we remove. Reading from the ref separately
118+
// can cause a race condition where we send message X but remove message Y.
119+
let messageToProcess: QueuedMessage | undefined
120+
121+
setQueuedMessages((prev) => {
122+
if (prev.length === 0) {
123+
return prev
124+
}
125+
messageToProcess = prev[0]
126+
const remainingMessages = prev.slice(1)
127+
queuedMessagesRef.current = remainingMessages
128+
return remainingMessages
130129
})
130+
131+
if (!messageToProcess) {
132+
isProcessingQueueRef.current = false
133+
return
134+
}
135+
136+
// Use .finally() to ensure lock is always released after sendMessage completes
137+
sendMessage(messageToProcess)
138+
.catch((err: unknown) => {
139+
logger.warn(
140+
{ error: err },
141+
'[message-queue] sendMessage promise rejected',
142+
)
143+
})
144+
.finally(() => {
145+
// Release the processing lock so the next message can be processed
146+
// The effect will re-run when streamStatus changes or other deps update
147+
isProcessingQueueRef.current = false
148+
logger.debug('[message-queue] Processing lock released')
149+
})
131150
}, [
132151
canProcessQueue,
133152
queuePaused,
134153
streamStatus,
154+
queuedMessages, // Re-run when queue changes to process next message
135155
sendMessage,
136156
isChainInProgressRef,
137157
activeAgentStreamsRef,
@@ -140,13 +160,19 @@ export const useMessageQueue = (
140160
const addToQueue = useCallback(
141161
(message: string, attachments: PendingAttachment[] = []) => {
142162
const queuedMessage = { content: message, attachments }
143-
const newQueue = [...queuedMessagesRef.current, queuedMessage]
144-
queuedMessagesRef.current = newQueue
145-
setQueuedMessages(newQueue)
146-
logger.info(
147-
{ newQueueLength: newQueue.length, messageLength: message.length },
148-
'[message-queue] Message added to queue',
149-
)
163+
// Use functional setState to ensure atomic updates during rapid calls.
164+
// We update queuedMessagesRef inside the callback to keep ref and state
165+
// in sync atomically - this prevents race conditions when multiple
166+
// messages are added before React can process state updates.
167+
setQueuedMessages((prev) => {
168+
const newQueue = [...prev, queuedMessage]
169+
queuedMessagesRef.current = newQueue
170+
logger.info(
171+
{ newQueueLength: newQueue.length, messageLength: message.length },
172+
'[message-queue] Message added to queue',
173+
)
174+
return newQueue
175+
})
150176
},
151177
[],
152178
)

scripts/tmux/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ Send input to a running session.
144144
./scripts/tmux/tmux-send.sh SESSION --key C-c
145145
./scripts/tmux/tmux-send.sh SESSION --key Enter
146146

147+
# Send and wait for CLI to finish streaming (for rapid message tests)
148+
./scripts/tmux/tmux-send.sh SESSION "hello" --wait-idle 2
149+
147150
# Paste clipboard content and submit immediately
148151
./scripts/tmux/tmux-send.sh SESSION --paste
149152

scripts/tmux/tmux-send.sh

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
# testing attachment UI before sending).
3535
# --no-enter Don't automatically press Enter after text
3636
# --retry N Retry session detection N times (default: 3)
37+
# --delay MS Wait time in ms after Enter (default: 500, use 200 for faster tests)
38+
# --wait-idle SECS Wait until terminal output is stable for SECS seconds (for streaming)
39+
# This polls every 250ms until output hasn't changed for SECS seconds.
40+
# Useful for rapid message testing where you need to wait for streaming.
41+
# Max wait time is 120 seconds to prevent infinite loops.
3742
# --force Bypass duplicate detection (send even if same text was just sent)
3843
# --help Show this help message
3944
#
@@ -50,6 +55,9 @@
5055
# # Send Ctrl+C to interrupt
5156
# ./scripts/tmux/tmux-send.sh tui-test-123 --key C-c
5257
#
58+
# # Send a message and wait for CLI to finish streaming before returning
59+
# ./scripts/tmux/tmux-send.sh tui-test-123 "hello" --wait-idle 2
60+
#
5361
# # Paste clipboard content and submit immediately
5462
# ./scripts/tmux/tmux-send.sh tui-test-123 --paste
5563
#
@@ -80,7 +88,11 @@ SPECIAL_KEY=""
8088
PASTE_CLIPBOARD=false
8189
RETRY_COUNT=3
8290
RETRY_DELAY=0.3
91+
POST_ENTER_DELAY=0.5
8392
FORCE_SEND=false
93+
WAIT_IDLE_SECONDS=0
94+
WAIT_IDLE_MAX=120
95+
WAIT_IDLE_POLL_INTERVAL=0.25
8496

8597
# Check minimum arguments
8698
if [[ $# -lt 1 ]]; then
@@ -120,6 +132,15 @@ while [[ $# -gt 0 ]]; do
120132
RETRY_COUNT="$2"
121133
shift 2
122134
;;
135+
--delay)
136+
# Convert ms to seconds for sleep command
137+
POST_ENTER_DELAY=$(echo "scale=3; $2 / 1000" | bc)
138+
shift 2
139+
;;
140+
--wait-idle)
141+
WAIT_IDLE_SECONDS="$2"
142+
shift 2
143+
;;
123144
--force)
124145
FORCE_SEND=true
125146
shift
@@ -249,8 +270,52 @@ if [[ "$AUTO_ENTER" == true ]]; then
249270
tmux send-keys -t "$SESSION_NAME" Enter
250271
# Wait for CLI to process Enter and clear input buffer before returning
251272
# This prevents the next send from concatenating with the previous input
252-
# 200ms is needed for slower CLIs like Codex to fully process the command
253-
sleep 0.2
273+
# Default 500ms is needed for TUI CLIs to fully process the command and reset input state
274+
# Use --delay to customize (e.g., --delay 200 for faster tests if not testing rapid input)
275+
sleep $POST_ENTER_DELAY
276+
fi
277+
278+
# If --wait-idle is specified, poll until terminal output stabilizes
279+
# This is essential for rapid message testing where we need to wait for streaming to complete
280+
# Works with both --auto-enter and --no-enter modes
281+
if [[ "$WAIT_IDLE_SECONDS" != "0" && -n "$WAIT_IDLE_SECONDS" ]]; then
282+
LAST_OUTPUT=""
283+
STABLE_START=0
284+
POLL_COUNT=0
285+
# Calculate max polls: WAIT_IDLE_MAX / WAIT_IDLE_POLL_INTERVAL (120 / 0.25 = 480)
286+
MAX_POLLS=480
287+
288+
while true; do
289+
# Capture current terminal output
290+
CURRENT_OUTPUT=$(tmux capture-pane -t "$SESSION_NAME" -p 2>/dev/null || echo "")
291+
CURRENT_TIME=$(date +%s)
292+
293+
if [[ "$CURRENT_OUTPUT" == "$LAST_OUTPUT" ]]; then
294+
# Output unchanged - check if stable long enough
295+
if [[ "$STABLE_START" == "0" ]]; then
296+
STABLE_START=$CURRENT_TIME
297+
fi
298+
299+
STABLE_DURATION=$((CURRENT_TIME - STABLE_START))
300+
if [[ "$STABLE_DURATION" -ge "$WAIT_IDLE_SECONDS" ]]; then
301+
# Output has been stable for the required duration
302+
break
303+
fi
304+
else
305+
# Output changed - reset stability timer
306+
LAST_OUTPUT="$CURRENT_OUTPUT"
307+
STABLE_START=0
308+
fi
309+
310+
# Check max wait timeout using simple integer counter
311+
POLL_COUNT=$((POLL_COUNT + 1))
312+
if [[ "$POLL_COUNT" -ge "$MAX_POLLS" ]]; then
313+
echo "⚠️ --wait-idle timed out after ${WAIT_IDLE_MAX}s" >&2
314+
break
315+
fi
316+
317+
sleep $WAIT_IDLE_POLL_INTERVAL
318+
done
254319
fi
255320

256321
# Log the text send as YAML and update last-sent tracker

0 commit comments

Comments
 (0)