fix(native-chat): preserve send pacing after renderer stalls

This commit is contained in:
Brennan Benson
2026-07-29 16:46:32 -07:00
parent 81404913d7
commit 0ab022571e
6 changed files with 140 additions and 42 deletions
@@ -9,6 +9,7 @@
export type NativeChatPtySendQueueHandle = {
cancel: () => void
settleAfterMs: number
settled: Promise<void>
bodyStarted: () => boolean
finished: () => boolean
}
@@ -97,6 +98,16 @@ export function enqueueNativeChatPtySend(
const timers: ReturnType<typeof setTimeout>[] = []
let release: (() => void) | null = null
const finishEntry = (): void => {
if (finished) {
return
}
finished = true
const resolve = release
release = null
resolve?.()
}
const delay = (ms: number, fn: () => void): void => {
const timer = setTimeout(() => {
if (!cancelled) {
@@ -106,19 +117,17 @@ export function enqueueNativeChatPtySend(
timers.push(timer)
}
const markFinished = (): void => {
finished = true
}
const markSubmitted = (): void => {
submitted = true
finishEntry()
}
const execute = (): Promise<void> =>
new Promise<void>((resolve) => {
release = resolve
if (cancelled) {
markFinished()
release = null
finished = true
resolve()
return
}
@@ -126,17 +135,7 @@ export function enqueueNativeChatPtySend(
start({ isCancelled: () => cancelled, delay, markSubmitted })
if (durationMs <= 0) {
markSubmitted()
markFinished()
resolve()
return
}
// Why: always release after the declared duration so a cancel mid-flight
// cannot stall the per-pty queue forever.
const done = setTimeout(() => {
markFinished()
resolve()
}, durationMs)
timers.push(done)
})
const runPromise =
@@ -148,7 +147,7 @@ export function enqueueNativeChatPtySend(
const settleQueueEntry = (): void => {
state.depth = Math.max(0, state.depth - 1)
markFinished()
finished = true
dropHandle()
// Why: drop the per-pty record once nothing is in flight so the map does not
// accumulate one permanent entry per pty over a long, multi-pane session.
@@ -157,7 +156,8 @@ export function enqueueNativeChatPtySend(
}
}
state.tail = runPromise.then(settleQueueEntry, settleQueueEntry)
const settled = runPromise.then(settleQueueEntry, settleQueueEntry)
state.tail = settled
const handle: NativeChatPtySendQueueHandle = {
cancel: () => {
@@ -169,20 +169,19 @@ export function enqueueNativeChatPtySend(
clearTimeout(timer)
}
const shouldClear = bodyStarted && !submitted
markFinished()
// Why: refund only THIS sequence's charged window rather than collapsing
// freeAt to now — later queued sends still hold the line, so a blanket
// reset would understate the next enqueue's settle time and let a send
// card drop while a queued Enter is still pending.
state.freeAt = Math.max(Date.now(), state.freeAt - Math.max(0, durationMs))
release?.()
release = null
finishEntry()
dropHandle()
if (shouldClear) {
options?.onCancelUnsubmitted?.()
}
},
settleAfterMs,
settled,
bodyStarted: () => bodyStarted,
finished: () => finished
}
@@ -69,6 +69,37 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => {
expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited'), NATIVE_CHAT_SUBMIT])
})
it('preserves the body-to-Enter gap when the renderer stalls past both nominal deadlines', async () => {
vi.useRealTimers()
const writeTimes = new Map<string, number>()
sendRuntimePtyInput.mockImplementation((_settings, _pty, bytes: string) => {
writeTimes.set(bytes, performance.now())
return true
})
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
clearInput: buildAgentTuiClearInputForText(DRAFT),
confirmCleared: () => true
})
sendNativeChatMessage(SETTINGS, PTY, 'queued')
const blockedUntil =
performance.now() + NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + 50
while (performance.now() < blockedUntil) {
// Simulate a renderer long task delaying both nominal deadlines.
}
await vi.waitFor(() => expect(writeTimes.has(NATIVE_CHAT_SUBMIT)).toBe(true), {
timeout: NATIVE_CHAT_SUBMIT_DELAY_MS + 1_000
})
await vi.waitFor(() => expect(writeTimes.has(buildNativeChatPasteBytes('queued'))).toBe(true))
expect(
writeTimes.get(NATIVE_CHAT_SUBMIT)! - writeTimes.get(buildNativeChatPasteBytes('edited'))!
).toBeGreaterThanOrEqual(NATIVE_CHAT_SUBMIT_DELAY_MS - 20)
expect(writes().indexOf(NATIVE_CHAT_SUBMIT)).toBeLessThan(
writes().indexOf(buildNativeChatPasteBytes('queued'))
)
})
it('widens to a maximal burst when the draft is still observed on the line', () => {
const clearInput = buildAgentTuiClearInputForText(DRAFT)
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
@@ -142,6 +142,23 @@ describe('sendNativeChatMessage', () => {
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6)
})
it('does not let a canceled queued send stall the sends behind it', async () => {
sendNativeChatMessage(SETTINGS, PTY, 'first')
const canceled = sendNativeChatMessage(SETTINGS, PTY, 'canceled')
sendNativeChatMessage(SETTINGS, PTY, 'third')
canceled.cancel()
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatPasteBytes('first'),
NATIVE_CHAT_SUBMIT,
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatPasteBytes('third')
])
})
it('does not serialize sends across different PTYs', () => {
sendNativeChatMessage(SETTINGS, 'pty-a', 'one')
sendNativeChatMessage(SETTINGS, 'pty-b', 'two')
@@ -63,6 +63,8 @@ export type NativeChatSendHandle = {
cancel: () => void
/** Time after which every scheduled write has fired and the handle can drop. */
settleAfterMs: number
/** Actual completion, which can outlive the nominal schedule if the renderer stalls. */
settled?: Promise<void>
}
type RuntimeSettings = ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
@@ -97,7 +99,13 @@ function clearThenWrite(
return
}
delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => {
if (!confirmCleared()) {
let cleared = false
try {
cleared = confirmCleared()
} catch {
// An unreadable terminal is unconfirmed; the maximal clear remains safe.
}
if (!cleared) {
sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX)
}
writeBody()
@@ -130,15 +138,17 @@ export function sendNativeChatMessage(
if (isCancelled()) {
return
}
delay(NATIVE_CHAT_SUBMIT_DELAY_MS + clearConfirmDurationMs(options), () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
markSubmitted()
})
clearThenWrite(settings, ptyId, options, delay, () => {
if (isCancelled()) {
return
}
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
// Schedule from the actual body write: an overdue clear-confirm callback
// must not collapse the required body-to-Enter gap after a renderer stall.
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
markSubmitted()
})
})
},
{
@@ -225,16 +235,6 @@ export function sendNativeChatMessageWithImageAttachments(
if (isCancelled()) {
return
}
const clearConfirmMs = clearConfirmDurationMs(options)
if (trimmedText.length > 0) {
delay(clearConfirmMs + NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => {
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
})
}
delay(durationMs, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
markSubmitted()
})
clearThenWrite(settings, ptyId, options, delay, () => {
if (isCancelled()) {
return
@@ -242,6 +242,20 @@ export function sendNativeChatMessageWithImageAttachments(
for (const imagePath of imagePaths) {
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
}
if (trimmedText.length > 0) {
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => {
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
markSubmitted()
})
})
return
}
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
markSubmitted()
})
})
},
{
@@ -66,4 +66,30 @@ describe('useNativeChatSendLifecycle', () => {
expect(settled.cancel).not.toHaveBeenCalled()
expect(onPendingSendCanceled).not.toHaveBeenCalled()
})
it('keeps a renderer-stalled send cancelable past its nominal schedule', async () => {
vi.useFakeTimers()
let resolveSettled!: () => void
const stalled = {
...handle(640),
settled: new Promise<void>((resolve) => {
resolveSettled = resolve
})
}
const onPendingSendCanceled = vi.fn()
const { result, rerender } = renderHook(
({ targetPtyId }) => useNativeChatSendLifecycle('tab-1', targetPtyId, onPendingSendCanceled),
{ initialProps: { targetPtyId: 'pty-1' as string | null } }
)
act(() => result.current.trackPendingSend(stalled, 'pending-1'))
act(() => vi.advanceTimersByTime(stalled.settleAfterMs + 1_000))
rerender({ targetPtyId: 'pty-2' })
expect(stalled.cancel).toHaveBeenCalledOnce()
expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-1')
await act(async () => {
resolveSettled()
})
})
})
@@ -14,13 +14,15 @@ export function useNativeChatSendLifecycle(
const pendingSendHandlesRef = useRef(
new Map<
NativeChatSendHandle,
{ cleanupTimer: ReturnType<typeof setTimeout>; pendingId?: string }
{ cleanupTimer: ReturnType<typeof setTimeout> | null; pendingId?: string }
>()
)
const cancelPendingSends = useCallback(() => {
for (const [handle, entry] of pendingSendHandlesRef.current) {
const { cleanupTimer, pendingId } = entry
clearTimeout(cleanupTimer)
if (cleanupTimer !== null) {
clearTimeout(cleanupTimer)
}
handle.cancel()
if (pendingId) {
onPendingSendCanceled?.(pendingId)
@@ -29,13 +31,22 @@ export function useNativeChatSendLifecycle(
pendingSendHandlesRef.current.clear()
}, [onPendingSendCanceled])
const trackPendingSend = useCallback((handle: NativeChatSendHandle, pendingId?: string) => {
const cleanupTimer = setTimeout(() => {
const entry = {
cleanupTimer: null as ReturnType<typeof setTimeout> | null,
...(pendingId ? { pendingId } : {})
}
pendingSendHandlesRef.current.set(handle, entry)
if (handle.settled) {
void handle.settled.then(() => {
if (pendingSendHandlesRef.current.get(handle) === entry) {
pendingSendHandlesRef.current.delete(handle)
}
})
return
}
entry.cleanupTimer = setTimeout(() => {
pendingSendHandlesRef.current.delete(handle)
}, handle.settleAfterMs)
pendingSendHandlesRef.current.set(handle, {
cleanupTimer,
...(pendingId ? { pendingId } : {})
})
}, [])
// Why: delayed Enter/image writes belong to the exact PTY target. A pane