fix(daemon): keep queueing writes while post-ready flush gate is pending

Codex review caught an ordering regression: once transitionToReady()
sets _shellState to 'ready', any Session.write() that arrives during
the 30–50ms gate window was being written directly to the subprocess,
bypassing the still-unflushed preReadyStdinQueue. That let late input
race ahead of the buffered startup command.

Expose PostReadyFlushGate.isPending and continue queuing while the gate
is armed so queued writes drain in their original order before any
fresh input reaches the subprocess.
This commit is contained in:
Neil
2026-04-24 15:18:35 -07:00
parent 5b3134fafd
commit 01af2337ef
4 changed files with 31 additions and 14 deletions
@@ -83,4 +83,15 @@ describe('PostReadyFlushGate', () => {
vi.advanceTimersByTime(POST_READY_FLUSH_DELAY_MS * 2)
expect(onFlush).not.toHaveBeenCalled()
})
it('isPending is true throughout the gate window and false once flush fires', () => {
expect(gate.isPending).toBe(false)
gate.arm()
expect(gate.isPending).toBe(true)
gate.notifyData()
expect(gate.isPending).toBe(true)
vi.advanceTimersByTime(POST_READY_FLUSH_DELAY_MS)
expect(gate.isPending).toBe(false)
expect(onFlush).toHaveBeenCalledTimes(1)
})
})
+6
View File
@@ -28,6 +28,12 @@ export class PostReadyFlushGate {
constructor(private readonly onFlush: () => void) {}
/** True between arm() and the actual flush firing. Callers should treat
* input as still-queued during this window to preserve ordering. */
get isPending(): boolean {
return this.awaitingPromptDraw || this.postDataTimer !== null || this.fallbackTimer !== null
}
/** Arm the gate after observing the shell-ready marker. Starts the
* wall-clock fallback; the flush fires when the fallback elapses or when
* notifyData() observes a subsequent PTY data chunk. */
+9 -13
View File
@@ -178,29 +178,25 @@ describe('Session', () => {
})
describe('shell readiness gating', () => {
it('buffers writes during pending state', () => {
createSession({ shellReadySupported: true })
expect(session.shellState).toBe('pending')
session.write('buffered input')
expect(subprocess.written).toEqual([])
})
// Why: regression guard for "claude claude" double-echo. The marker fires
// from precmd before readline switches the PTY into raw mode; flushing
// then lets the kernel re-echo the command under the prompt. Detailed
// timing behavior is covered by post-ready-flush-gate.test.ts; here we
// just verify Session wires the gate in (no write on the raw marker).
it('defers flush past the shell-ready marker to avoid kernel ECHO duplication', () => {
// timing behavior is covered by post-ready-flush-gate.test.ts.
// Also checks writes that arrive during the gate window keep their order
// — the gate continues to queue even though shellState is already 'ready'.
it('defers flush past the shell-ready marker and preserves write order', () => {
createSession({ shellReadySupported: true })
session.write('claude\n')
expect(session.shellState).toBe('pending')
session.write('first\n')
subprocess.simulateData('\x1b]777;orca-shell-ready\x07')
expect(session.shellState).toBe('ready' satisfies ShellReadyState)
session.write('second\n')
expect(subprocess.written).toEqual([])
subprocess.simulateData('\r\nuser@host $ ')
vi.advanceTimersByTime(30)
expect(subprocess.written).toEqual(['claude\n'])
expect(subprocess.written).toEqual(['first\n', 'second\n'])
})
it('transitions to timed_out after 15 seconds', () => {
+5 -1
View File
@@ -106,7 +106,11 @@ export class Session {
return
}
if (this._shellState === 'pending') {
// Why: during the post-ready flush gate window (shellState is already
// 'ready' but the queue hasn't flushed yet) we must keep queuing. Writing
// directly would let fresh input race ahead of the buffered startup
// command, changing execution order.
if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) {
this.preReadyStdinQueue.push(data)
return
}