From 01af2337efa304d48a5d7cbb4d2c369e5af25f69 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:18:35 -0700 Subject: [PATCH] fix(daemon): keep queueing writes while post-ready flush gate is pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/main/daemon/post-ready-flush-gate.test.ts | 11 ++++++++++ src/main/daemon/post-ready-flush-gate.ts | 6 +++++ src/main/daemon/session.test.ts | 22 ++++++++----------- src/main/daemon/session.ts | 6 ++++- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/main/daemon/post-ready-flush-gate.test.ts b/src/main/daemon/post-ready-flush-gate.test.ts index 6614f3fe79b..49379a53bad 100644 --- a/src/main/daemon/post-ready-flush-gate.test.ts +++ b/src/main/daemon/post-ready-flush-gate.test.ts @@ -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) + }) }) diff --git a/src/main/daemon/post-ready-flush-gate.ts b/src/main/daemon/post-ready-flush-gate.ts index a78442bdf2b..2e15b184068 100644 --- a/src/main/daemon/post-ready-flush-gate.ts +++ b/src/main/daemon/post-ready-flush-gate.ts @@ -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. */ diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 1fc7307778b..5191f77fd6b 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -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', () => { diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7ed9bf17019..4c398f5a2e0 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -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 }