Fix terminal stream backpressure and split-pane input lag (#2341)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-19 16:59:01 -04:00 committed by GitHub
parent 3333442932
commit f010bb8619
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1079 additions and 78 deletions

View File

@ -50,10 +50,14 @@ describe('DaemonClient', () => {
function startMockDaemon(opts?: {
onControlMessage?: (msg: unknown) => string | null
onStreamHello?: (msg: HelloMessage) => void
streamHelloGate?: Promise<void>
rejectVersion?: boolean
}): Promise<void> {
return new Promise((resolve) => {
server = createServer((socket) => {
socket.on('error', () => {
/* tests intentionally destroy sockets mid-handshake */
})
let buffer = ''
socket.on('data', (chunk) => {
buffer += chunk.toString()
@ -73,10 +77,22 @@ describe('DaemonClient', () => {
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Version mismatch' }))
return
}
socket.write(encodeNdjson({ type: 'hello', ok: true }))
if (hello.role === 'stream') {
opts?.onStreamHello?.(hello)
if (opts?.streamHelloGate) {
void opts.streamHelloGate.then(() => {
if (!socket.destroyed) {
try {
socket.write(encodeNdjson({ type: 'hello', ok: true }))
} catch {
/* client intentionally disconnected during handshake */
}
}
})
return
}
}
socket.write(encodeNdjson({ type: 'hello', ok: true }))
} else if (opts?.onControlMessage) {
const response = opts.onControlMessage(msg)
if (response) {
@ -252,6 +268,30 @@ describe('DaemonClient', () => {
client = new DaemonClient({ socketPath, tokenPath })
expect(() => client.disconnect()).not.toThrow()
})
it('disconnect() cancels an in-flight connection attempt', async () => {
let releaseStreamHello!: () => void
const streamHelloGate = new Promise<void>((resolve) => {
releaseStreamHello = resolve
})
let sawStreamHello = false
await startMockDaemon({
streamHelloGate,
onStreamHello: () => {
sawStreamHello = true
}
})
client = new DaemonClient({ socketPath, tokenPath })
const connectPromise = client.ensureConnected()
await waitFor(() => sawStreamHello)
client.disconnect()
releaseStreamHello()
await expect(connectPromise).rejects.toThrow()
expect(client.isConnected()).toBe(false)
})
})
describe('notify (fire-and-forget)', () => {

View File

@ -31,6 +31,7 @@ export class DaemonClient {
private streamSocket: Socket | null = null
private connected = false
private disconnectArmed = false
private disconnectGeneration = 0
// Why: after a disconnect + reconnect (daemon respawn), a stale 'close'
// event from the old sockets can fire. Without a generation check, that
// event would tear down the fresh connection. Each doConnect() increments
@ -74,15 +75,20 @@ export class DaemonClient {
private async doConnect(): Promise<void> {
const token = readFileSync(this.tokenPath, 'utf-8').trim()
const disconnectGeneration = this.disconnectGeneration
try {
// Sequential: control first, then stream
this.controlSocket = await this.connectSocket()
this.assertConnectNotCancelled(disconnectGeneration)
await this.sendHello(this.controlSocket, token, 'control')
this.assertConnectNotCancelled(disconnectGeneration)
this.setupControlParser()
this.streamSocket = await this.connectSocket()
this.assertConnectNotCancelled(disconnectGeneration)
await this.sendHello(this.streamSocket, token, 'stream')
this.assertConnectNotCancelled(disconnectGeneration)
this.setupStreamParser()
this.connected = true
@ -90,11 +96,18 @@ export class DaemonClient {
this.connectionGeneration++
const gen = this.connectionGeneration
const handleClose = () => this.handleDisconnect(gen)
this.controlSocket.on('close', handleClose)
this.controlSocket.on('error', handleClose)
this.streamSocket.on('close', handleClose)
this.streamSocket.on('error', handleClose)
this.controlSocket.on('close', (hadError) =>
this.handleDisconnect(gen, `control socket closed${hadError ? ' after error' : ''}`)
)
this.controlSocket.on('error', (error) =>
this.handleDisconnect(gen, `control socket error: ${error.message}`)
)
this.streamSocket.on('close', (hadError) =>
this.handleDisconnect(gen, `stream socket closed${hadError ? ' after error' : ''}`)
)
this.streamSocket.on('error', (error) =>
this.handleDisconnect(gen, `stream socket error: ${error.message}`)
)
} catch (error) {
this.controlSocket?.destroy()
this.streamSocket?.destroy()
@ -130,14 +143,19 @@ export class DaemonClient {
})
}
notify(type: string, payload: unknown): void {
notify(type: string, payload: unknown): boolean {
if (!this.connected || !this.controlSocket) {
return
return false
}
const id = `${NOTIFY_PREFIX}${++this.requestCounter}`
const msg = { id, type, ...(payload !== undefined ? { payload } : {}) }
this.controlSocket.write(encodeNdjson(msg))
try {
this.controlSocket.write(encodeNdjson(msg))
return true
} catch {
return false
}
}
onEvent(listener: (event: unknown) => void): () => void {
@ -161,8 +179,10 @@ export class DaemonClient {
}
disconnect(): void {
this.disconnectGeneration++
this.connected = false
this.disconnectArmed = false
this.connectingPromise = null
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer)
@ -176,6 +196,12 @@ export class DaemonClient {
this.streamSocket = null
}
private assertConnectNotCancelled(disconnectGeneration: number): void {
if (disconnectGeneration !== this.disconnectGeneration) {
throw new DaemonProtocolError('Connection cancelled')
}
}
private connectSocket(): Promise<Socket> {
return new Promise((resolve, reject) => {
const socket = connect(this.socketPath)
@ -207,6 +233,11 @@ export class DaemonClient {
}
let buffer = ''
const cleanup = (): void => {
socket.removeListener('data', onData)
socket.removeListener('error', onError)
socket.removeListener('close', onClose)
}
const onData = (chunk: Buffer): void => {
buffer += chunk.toString()
const newlineIdx = buffer.indexOf('\n')
@ -214,7 +245,7 @@ export class DaemonClient {
return
}
socket.removeListener('data', onData)
cleanup()
const line = buffer.slice(0, newlineIdx)
try {
const response = JSON.parse(line) as HelloResponse
@ -229,8 +260,18 @@ export class DaemonClient {
reject(new DaemonProtocolError('Invalid hello response'))
}
}
const onError = (error: Error): void => {
cleanup()
reject(error)
}
const onClose = (): void => {
cleanup()
reject(new DaemonProtocolError('Connection closed during hello'))
}
socket.on('data', onData)
socket.once('error', onError)
socket.once('close', onClose)
socket.write(encodeNdjson(hello))
})
}
@ -282,13 +323,18 @@ export class DaemonClient {
this.streamSocket.on('data', (chunk) => parser.feed(chunk.toString()))
}
private handleDisconnect(generation: number): void {
private handleDisconnect(generation: number, reason: string): void {
if (!this.disconnectArmed || generation !== this.connectionGeneration) {
return
}
this.disconnectArmed = false
this.connected = false
console.warn('[daemon-client] disconnected', {
reason,
pendingRequests: this.pendingRequests.size
})
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer)
pending.reject(new DaemonProtocolError('Connection lost'))

View File

@ -114,6 +114,22 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await new Promise((r) => setTimeout(r, 50))
expect(lastSubprocess.write).toHaveBeenCalledWith('ls\n')
})
it('reattaches active sessions and flushes writes after daemon stream failure', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
lastSubprocess._simulateData('x'.repeat(8 * 1024 * 1024 + 1))
await new Promise((resolve) => setTimeout(resolve, 100))
adapter.write(id, 'after-reconnect\n')
await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0, 3000)
expect(lastSubprocess.write).toHaveBeenCalledWith('after-reconnect\n')
} finally {
warn.mockRestore()
}
})
})
describe('resize', () => {

View File

@ -31,6 +31,12 @@ export type DaemonPtyAdapterOptions = {
}
const MAX_TOMBSTONES = 1000
const MAX_PENDING_DAEMON_NOTIFICATIONS = 512
type PendingDaemonNotification = {
type: 'write' | 'resize'
payload: unknown
}
export class TerminalKilledError extends Error {
constructor(sessionId: string) {
@ -53,6 +59,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
private dataListeners: ((payload: { id: string; data: string }) => void)[] = []
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
private removeEventListener: (() => void) | null = null
private removeDisconnectedListener: (() => void) | null = null
private recoveryPromise: Promise<void> | null = null
private pendingNotifications: PendingDaemonNotification[] = []
private disposed = false
private initialCwds = new Map<string, string>()
// Why: React re-renders and StrictMode double-mounts can call createOrAttach
// for a session the user just killed. Without tombstones, the daemon would
@ -60,6 +70,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Uses a Map<id, timestamp> so eviction removes the oldest by insertion order,
// matching terminal-host.ts tombstone semantics.
private killedSessionTombstones = new Map<string, number>()
private sessionSizes = new Map<string, { cols: number; rows: number }>()
// Why: React StrictMode double-mounts: mount → cold restore → unmount →
// mount → ??? The sticky cache returns the same cold restore data on the
// second mount until the renderer explicitly acknowledges it.
@ -84,6 +95,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null
this.respawnFn = opts.respawn ?? null
this.supportsCheckpoints = this.protocolVersion >= 4
this.removeDisconnectedListener = this.client.onDisconnected(() => {
void this.recoverActiveSessionsAfterDisconnect().catch((err) =>
console.warn('[daemon] reconnect after stream failure failed:', err)
)
})
}
getHistoryManager(): HistoryManager | null {
@ -151,6 +167,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
this.activeSessionIds.add(sessionId)
this.sessionSizes.set(sessionId, { cols: effectiveCols, rows: effectiveRows })
// Cold restore: daemon created a new session but disk history shows
// an unclean shutdown → return saved scrollback so the renderer can
@ -230,12 +247,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
write(id: string, data: string): void {
this.markSessionDirty(id)
this.client.notify('write', { sessionId: id, data })
this.sendNotification('write', { sessionId: id, data })
}
resize(id: string, cols: number, rows: number): void {
this.markSessionDirty(id)
this.client.notify('resize', { sessionId: id, cols, rows })
this.sessionSizes.set(id, { cols, rows })
this.sendNotification('resize', { sessionId: id, cols, rows })
}
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
@ -243,6 +261,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.activeSessionIds.delete(id)
this.dirtySessionVersions.delete(id)
this.initialCwds.delete(id)
this.sessionSizes.delete(id)
// Why: history removal is for the "user explicitly closed this terminal"
// path. Sleep also calls shutdown but expects scrollback to survive — wake
// re-spawns and the cold-restore reader needs the dir intact. Caller
@ -359,6 +378,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
// set, disconnectOnly()'s final checkpoint would skip them, leaving
// stale recovery data if the daemon later crashes.
this.activeSessionIds.add(session.sessionId)
this.sessionSizes.set(session.sessionId, { cols: session.cols, rows: session.rows })
this.historyManager?.registerWriter(session.sessionId)
}
}
@ -403,6 +423,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
const ids = [...this.activeSessionIds]
this.activeSessionIds.clear()
this.dirtySessionVersions.clear()
this.sessionSizes.clear()
for (const id of ids) {
// Why: listener throws are intentionally *not* caught — matches the
// natural onExit fanout in setupEventRouting, so synthetic exits don't
@ -458,11 +479,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
dispose(): void {
this.disposed = true
if (this.checkpointInterval) {
clearInterval(this.checkpointInterval)
this.checkpointInterval = null
}
this.dirtySessionVersions.clear()
this.sessionSizes.clear()
this.removeEventListener?.()
this.removeEventListener = null
// Why: final checkpoints are written daemon-side in TerminalHost.dispose()
@ -474,6 +497,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
.catch((err) => console.warn('[history] dispose failed:', err))
}
this.client.disconnect()
this.removeDisconnectedListener?.()
this.removeDisconnectedListener = null
this.pendingNotifications = []
}
// Why: for in-process daemon mode, disconnect without flushing history.
@ -483,6 +509,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
// We write a final checkpoint before disconnecting so that if the daemon
// later crashes while Orca is closed, checkpoint.json has recovery data.
async disconnectOnly(): Promise<void> {
this.disposed = true
if (this.checkpointInterval) {
clearInterval(this.checkpointInterval)
this.checkpointInterval = null
@ -503,6 +530,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.removeEventListener?.()
this.removeEventListener = null
this.client.disconnect()
this.removeDisconnectedListener?.()
this.removeDisconnectedListener = null
this.pendingNotifications = []
}
private async ensureConnected(): Promise<void> {
@ -529,6 +559,72 @@ export class DaemonPtyAdapter implements IPtyProvider {
}, DaemonPtyAdapter.CHECKPOINT_INTERVAL_MS)
}
private sendNotification(type: PendingDaemonNotification['type'], payload: unknown): void {
if (this.recoveryPromise) {
this.queueNotification(type, payload)
return
}
if (this.client.notify(type, payload)) {
return
}
this.queueNotification(type, payload)
void this.recoverActiveSessionsAfterDisconnect().catch((err) =>
console.warn('[daemon] reconnect after notification failure failed:', err)
)
}
private queueNotification(type: PendingDaemonNotification['type'], payload: unknown): void {
this.pendingNotifications.push({ type, payload })
if (this.pendingNotifications.length > MAX_PENDING_DAEMON_NOTIFICATIONS) {
this.pendingNotifications.splice(
0,
this.pendingNotifications.length - MAX_PENDING_DAEMON_NOTIFICATIONS
)
}
}
private async recoverActiveSessionsAfterDisconnect(): Promise<void> {
if (this.disposed || this.activeSessionIds.size === 0) {
return
}
if (!this.recoveryPromise) {
this.recoveryPromise = this.reattachActiveSessions().finally(() => {
this.recoveryPromise = null
})
}
await this.recoveryPromise
}
private async reattachActiveSessions(): Promise<void> {
await this.ensureConnected()
// Why: daemon stream failure only breaks the renderer socket pair; the
// backing PTYs stay alive in TerminalHost. Reattach active sessions so
// stream events resume instead of letting panes black-hole input.
for (const sessionId of this.activeSessionIds) {
const size = this.sessionSizes.get(sessionId) ?? { cols: 80, rows: 24 }
await this.client.request<CreateOrAttachResult>('createOrAttach', {
sessionId,
cols: size.cols,
rows: size.rows
})
}
this.flushPendingNotifications()
}
private flushPendingNotifications(): void {
const pending = this.pendingNotifications
this.pendingNotifications = []
for (const notification of pending) {
if (!this.client.notify(notification.type, notification.payload)) {
this.queueNotification(notification.type, notification.payload)
void this.recoverActiveSessionsAfterDisconnect().catch((err) =>
console.warn('[daemon] reconnect after pending notification failed:', err)
)
return
}
}
}
private markSessionDirty(sessionId: string): void {
if (!this.activeSessionIds.has(sessionId)) {
return
@ -646,6 +742,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
} else if (event.event === 'exit') {
this.activeSessionIds.delete(event.sessionId)
this.dirtySessionVersions.delete(event.sessionId)
this.sessionSizes.delete(event.sessionId)
if (this.historyManager) {
void this.historyManager
.closeSession(event.sessionId, event.payload.code)

View File

@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect } from 'net'
import { connect, type Socket } from 'net'
import { tmpdir } from 'os'
import { join } from 'path'
import { mkdtempSync, rmSync, readFileSync } from 'fs'
@ -66,6 +66,39 @@ describe('DaemonServer', () => {
return client
}
async function connectRawSocket(role: 'control' | 'stream', clientId: string): Promise<Socket> {
const socket = connect(socketPath)
await new Promise<void>((resolve) => socket.on('connect', resolve))
socket.write(
encodeNdjson({
type: 'hello',
version: PROTOCOL_VERSION,
token: readFileSync(tokenPath, 'utf-8').trim(),
clientId,
role
})
)
const response = await readSocketLine(socket)
expect(JSON.parse(response)).toMatchObject({ ok: true })
return socket
}
function readSocketLine(socket: Socket): Promise<string> {
return new Promise((resolve) => {
let buffer = ''
const onData = (chunk: Buffer): void => {
buffer += chunk.toString()
const newlineIdx = buffer.indexOf('\n')
if (newlineIdx === -1) {
return
}
socket.removeListener('data', onData)
resolve(buffer.slice(0, newlineIdx))
}
socket.on('data', onData)
})
}
describe('startup', () => {
it('creates token file and starts listening', async () => {
await startServer()
@ -122,6 +155,22 @@ describe('DaemonServer', () => {
expect(result).toEqual({ pong: true })
})
it('keeps a replacement control socket alive when the old socket closes later', async () => {
await startServer()
const firstControl = await connectRawSocket('control', 'same-client')
const secondControl = await connectRawSocket('control', 'same-client')
await new Promise((resolve) => setTimeout(resolve, 20))
secondControl.write(encodeNdjson({ id: 'req-1', type: 'ping' }))
await expect(readSocketLine(secondControl)).resolves.toMatch(
/"id":"req-1".*"payload":\{"pong":true\}/
)
firstControl.destroy()
secondControl.destroy()
})
it('handles write (fire-and-forget)', async () => {
await startServer()
const c = await connectClient()

View File

@ -41,7 +41,12 @@ export class DaemonServer {
private tokenPath: string
private clients = new Map<string, ConnectedClient>()
private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId))
private streamDataBatcher = new DaemonStreamDataBatcher(
(clientId) => this.clients.get(clientId),
{
onStreamFailure: (clientId) => this.disconnectClient(clientId)
}
)
constructor(opts: DaemonServerOptions) {
this.socketPath = opts.socketPath
@ -134,6 +139,7 @@ export class DaemonServer {
socket.write(encodeNdjson({ type: 'hello', ok: true }))
if (hello.role === 'control') {
this.disconnectClient(hello.clientId)
const client: ConnectedClient = {
clientId: hello.clientId,
controlSocket: socket,
@ -144,6 +150,8 @@ export class DaemonServer {
} else if (hello.role === 'stream') {
const client = this.clients.get(hello.clientId)
if (client) {
this.streamDataBatcher.clear(hello.clientId)
client.streamSocket?.destroy()
client.streamSocket = socket
}
// Stream socket is receive-only from daemon's perspective (for events)
@ -161,8 +169,10 @@ export class DaemonServer {
socket.on('data', (chunk) => parser.feed(chunk.toString()))
socket.on('close', () => {
this.streamDataBatcher.clear(clientId)
this.clients.delete(clientId)
const client = this.clients.get(clientId)
if (client?.controlSocket === socket) {
this.disconnectClient(clientId, client)
}
})
}
@ -212,19 +222,10 @@ export class DaemonServer {
this.streamDataBatcher.enqueue(clientId, p.sessionId, data)
},
onExit: (code) => {
// Why: exit tears down renderer handlers; flush final output first
// so the last few milliseconds of PTY data are not stranded.
this.streamDataBatcher.flush(clientId)
if (client?.streamSocket) {
client.streamSocket.write(
encodeNdjson({
type: 'event',
event: 'exit',
sessionId: p.sessionId,
payload: { code }
})
)
}
// Why: exit tears down renderer handlers; queue it behind any
// pending data so the final PTY bytes cannot be overtaken under
// stream backpressure.
this.streamDataBatcher.enqueueExit(clientId, p.sessionId, code)
}
}
})
@ -304,19 +305,23 @@ export class DaemonServer {
sessionId: string,
code: number
): void {
if (!client?.streamSocket) {
if (!client) {
return
}
// Why: write/resize are notification-heavy and intentionally do not wait
// for replies. If their target session is gone, this synthetic exit is the
// only signal the renderer gets to clear stale terminal pane bindings.
client.streamSocket.write(
encodeNdjson({
type: 'event',
event: 'exit',
sessionId,
payload: { code }
})
)
this.streamDataBatcher.enqueueExit(client.clientId, sessionId, code)
}
private disconnectClient(clientId: string, expectedClient?: ConnectedClient): void {
const client = this.clients.get(clientId)
if (expectedClient && client !== expectedClient) {
return
}
this.streamDataBatcher.clear(clientId)
this.clients.delete(clientId)
client?.streamSocket?.destroy()
client?.controlSocket.destroy()
}
}

View File

@ -0,0 +1,309 @@
import { describe, expect, it, vi } from 'vitest'
import type { Socket } from 'net'
import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
function createFakeSocket(writeResults: boolean[]): {
socket: Socket
write: ReturnType<typeof vi.fn>
removeListener: ReturnType<typeof vi.fn>
drain: () => void
close: () => void
error: () => void
staleDrain: () => void
staleClose: () => void
staleError: () => void
} {
let drainHandler: (() => void) | null = null
let closeHandler: (() => void) | null = null
let errorHandler: (() => void) | null = null
let removedDrainHandler: (() => void) | null = null
let removedCloseHandler: (() => void) | null = null
let removedErrorHandler: (() => void) | null = null
const write = vi.fn(() => writeResults.shift() ?? true)
const removeListener = vi.fn((event: string, handler: () => void) => {
if (event === 'drain' && drainHandler === handler) {
removedDrainHandler = handler
drainHandler = null
} else if (event === 'close' && closeHandler === handler) {
removedCloseHandler = handler
closeHandler = null
} else if (event === 'error' && errorHandler === handler) {
removedErrorHandler = handler
errorHandler = null
}
return socket
})
const socket = {
destroyed: false,
write,
removeListener,
once: vi.fn((event: string, handler: () => void) => {
if (event === 'drain') {
drainHandler = handler
} else if (event === 'close') {
closeHandler = handler
} else if (event === 'error') {
errorHandler = handler
}
return socket
})
} as unknown as Socket
return {
socket,
write,
removeListener,
drain: () => drainHandler?.(),
close: () => closeHandler?.(),
error: () => errorHandler?.(),
staleDrain: () => removedDrainHandler?.(),
staleClose: () => removedCloseHandler?.(),
staleError: () => removedErrorHandler?.()
}
}
describe('DaemonStreamDataBatcher', () => {
it('drops queued output when the backpressured stream errors before drain', () => {
const fake = createFakeSocket([false, true])
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket: fake.socket }))
batcher.enqueue('client-1', 'session-a', 'first')
batcher.enqueue('client-1', 'session-b', 'second')
batcher.flush('client-1')
fake.error()
fake.drain()
expect(fake.write).toHaveBeenCalledTimes(1)
})
it('cleans up unused backpressure listeners after drain', () => {
const fake = createFakeSocket([false, true])
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket: fake.socket }))
batcher.enqueue('client-1', 'session-a', 'first')
batcher.enqueue('client-1', 'session-b', 'second')
batcher.flush('client-1')
fake.drain()
expect(fake.removeListener).toHaveBeenCalledWith('close', expect.any(Function))
expect(fake.removeListener).toHaveBeenCalledWith('error', expect.any(Function))
fake.close()
expect(fake.write).toHaveBeenCalledTimes(2)
})
it('drops queued output when the backpressured stream closes before drain', () => {
const fake = createFakeSocket([false, true])
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket: fake.socket }))
batcher.enqueue('client-1', 'session-a', 'first')
batcher.enqueue('client-1', 'session-b', 'second')
batcher.flush('client-1')
fake.close()
fake.drain()
expect(fake.write).toHaveBeenCalledTimes(1)
})
it('does not keep queued output forever when drain never arrives', () => {
vi.useFakeTimers()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const fake = createFakeSocket([false, true])
const onStreamFailure = vi.fn()
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket: fake.socket }), {
onStreamFailure
})
batcher.enqueue('client-1', 'session-a', 'first')
batcher.enqueue('client-1', 'session-b', 'second')
batcher.flush('client-1')
vi.advanceTimersByTime(30_000)
fake.drain()
expect(fake.write).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith(
'[daemon] PTY stream socket drain timed out',
expect.objectContaining({ clientId: 'client-1' })
)
expect(onStreamFailure).toHaveBeenCalledWith('client-1')
} finally {
warn.mockRestore()
vi.useRealTimers()
}
})
it('orders exit behind queued data while waiting for drain', () => {
const fake = createFakeSocket([false, true, true])
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket: fake.socket }))
batcher.enqueue('client-1', 'session-a', 'first')
batcher.flush('client-1')
batcher.enqueue('client-1', 'session-b', 'second')
batcher.enqueueExit('client-1', 'session-a', 0)
expect(fake.write).toHaveBeenCalledTimes(1)
fake.drain()
expect(fake.write).toHaveBeenCalledTimes(3)
expect(fake.write.mock.calls[1]?.[0]).toContain('"data"')
expect(fake.write.mock.calls[1]?.[0]).toContain('second')
expect(fake.write.mock.calls[2]?.[0]).toContain('"exit"')
})
it('waits for socket drain before continuing after stream backpressure', () => {
const fake = createFakeSocket([false, true])
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket: fake.socket }))
batcher.enqueue('client-1', 'session-a', 'first')
batcher.enqueue('client-1', 'session-b', 'second')
batcher.flush('client-1')
expect(fake.write).toHaveBeenCalledTimes(1)
expect(fake.write.mock.calls[0]?.[0]).toContain('first')
fake.drain()
expect(fake.write).toHaveBeenCalledTimes(2)
expect(fake.write.mock.calls[1]?.[0]).toContain('second')
})
it('queues additional output while waiting for drain', () => {
const fake = createFakeSocket([false, true])
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket: fake.socket }))
batcher.enqueue('client-1', 'session-a', 'first')
batcher.flush('client-1')
batcher.enqueue('client-1', 'session-b', 'second')
batcher.flush('client-1')
expect(fake.write).toHaveBeenCalledTimes(1)
fake.drain()
expect(fake.write).toHaveBeenCalledTimes(2)
expect(fake.write.mock.calls[1]?.[0]).toContain('second')
})
it('ignores stale drain callbacks after clearing an old client stream', () => {
const oldStream = createFakeSocket([false, true])
const newStream = createFakeSocket([true])
let streamSocket = oldStream.socket
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket }))
batcher.enqueue('client-1', 'session-a', 'first')
batcher.enqueue('client-1', 'session-b', 'second')
batcher.flush('client-1')
batcher.clear('client-1')
streamSocket = newStream.socket
batcher.enqueue('client-1', 'session-c', 'third')
batcher.flush('client-1')
oldStream.staleDrain()
expect(oldStream.write).toHaveBeenCalledTimes(1)
expect(newStream.write).toHaveBeenCalledTimes(1)
expect(newStream.write.mock.calls[0]?.[0]).toContain('third')
})
it('ignores stale close and error callbacks after clearing an old client stream', () => {
const oldStream = createFakeSocket([false, true])
const newStream = createFakeSocket([true])
let streamSocket = oldStream.socket
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket }))
batcher.enqueue('client-1', 'session-a', 'first')
batcher.enqueue('client-1', 'session-b', 'second')
batcher.flush('client-1')
batcher.clear('client-1')
streamSocket = newStream.socket
batcher.enqueue('client-1', 'session-c', 'third')
batcher.flush('client-1')
oldStream.staleClose()
oldStream.staleError()
expect(oldStream.write).toHaveBeenCalledTimes(1)
expect(newStream.write).toHaveBeenCalledTimes(1)
expect(newStream.write.mock.calls[0]?.[0]).toContain('third')
})
it('fails the stream when queued output exceeds the hard cap', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const fake = createFakeSocket([true])
const onStreamFailure = vi.fn()
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket: fake.socket }), {
onStreamFailure
})
batcher.enqueue('client-1', 'session-a', 'x'.repeat(8 * 1024 * 1024 + 1))
expect(fake.write).not.toHaveBeenCalled()
expect(onStreamFailure).toHaveBeenCalledWith('client-1')
expect(warn).toHaveBeenCalledWith(
'[daemon] PTY stream socket queue exceeded limit',
expect.objectContaining({ clientId: 'client-1' })
)
} finally {
warn.mockRestore()
}
})
it('fails the stream when queued output cumulatively exceeds the hard cap while backpressured', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const fake = createFakeSocket([false])
const onStreamFailure = vi.fn()
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket: fake.socket }), {
onStreamFailure
})
batcher.enqueue('client-1', 'session-a', 'first')
batcher.flush('client-1')
batcher.enqueue('client-1', 'session-a', 'x'.repeat(8 * 1024 * 1024 + 1))
expect(fake.write).toHaveBeenCalledTimes(1)
expect(onStreamFailure).toHaveBeenCalledWith('client-1')
expect(warn).toHaveBeenCalledWith(
'[daemon] PTY stream socket queue exceeded limit',
expect.objectContaining({ clientId: 'client-1' })
)
} finally {
warn.mockRestore()
}
})
it('splits very large output into bounded stream frames', () => {
const fake = createFakeSocket([true, true])
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket: fake.socket }))
batcher.enqueue('client-1', 'session-a', 'x'.repeat(65 * 1024))
batcher.flush('client-1')
expect(fake.write).toHaveBeenCalledTimes(2)
})
it('yields between large queued flushes', () => {
vi.useFakeTimers()
try {
const fake = createFakeSocket(Array.from({ length: 1025 }, () => true))
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket: fake.socket }))
for (let i = 0; i < 1025; i++) {
batcher.enqueue('client-1', `session-${i}`, `${i}`)
}
batcher.flush('client-1')
expect(fake.write).toHaveBeenCalledTimes(1024)
vi.advanceTimersByTime(0)
expect(fake.write).toHaveBeenCalledTimes(1025)
} finally {
vi.useRealTimers()
}
})
})

View File

@ -5,45 +5,115 @@ type StreamDataClient = {
streamSocket: Socket | null
}
type PendingStreamEvent =
| { kind: 'data'; sessionId: string; data: string }
| { kind: 'exit'; sessionId: string; code: number }
type PendingStreamDataBatch = {
timer: ReturnType<typeof setTimeout> | null
queue: { sessionId: string; data: string }[]
drainTimer: ReturnType<typeof setTimeout> | null
cleanupWait: (() => void) | null
queue: PendingStreamEvent[]
queueHead: number
queuedDataBytes: number
waitingForDrain: boolean
warnedBackpressure: boolean
}
// Why: match main-process PTY IPC batching to avoid adding latency while
// removing daemon socket writes and JSON framing during bursty output.
const STREAM_DATA_BATCH_INTERVAL_MS = 8
const STREAM_DATA_BACKPRESSURE_WARN_BYTES = 512 * 1024
const STREAM_DATA_DRAIN_TIMEOUT_MS = 30_000
const STREAM_DATA_MAX_QUEUED_BYTES = 8 * 1024 * 1024
const STREAM_DATA_MAX_PAYLOAD_CHARS = 64 * 1024
const STREAM_DATA_MAX_EVENTS_PER_FLUSH = 1024
export class DaemonStreamDataBatcher {
private pendingByClient = new Map<string, PendingStreamDataBatch>()
private getClient: (clientId: string) => StreamDataClient | undefined
private onStreamFailure: (clientId: string) => void
constructor(getClient: (clientId: string) => StreamDataClient | undefined) {
constructor(
getClient: (clientId: string) => StreamDataClient | undefined,
opts: { onStreamFailure?: (clientId: string) => void } = {}
) {
this.getClient = getClient
this.onStreamFailure = opts.onStreamFailure ?? (() => {})
}
enqueue(clientId: string, sessionId: string, data: string): void {
for (let offset = 0; offset < data.length; offset += STREAM_DATA_MAX_PAYLOAD_CHARS) {
const shouldContinue = this.enqueueEvent(clientId, {
kind: 'data',
sessionId,
data: data.slice(offset, offset + STREAM_DATA_MAX_PAYLOAD_CHARS)
})
if (!shouldContinue) {
return
}
}
}
enqueueExit(clientId: string, sessionId: string, code: number): void {
this.enqueueEvent(clientId, { kind: 'exit', sessionId, code })
this.flush(clientId)
}
private enqueueEvent(clientId: string, event: PendingStreamEvent): boolean {
const client = this.getClient(clientId)
if (!client?.streamSocket || client.streamSocket.destroyed) {
return
return false
}
let batch = this.pendingByClient.get(clientId)
if (!batch) {
batch = { timer: null, queue: [] }
batch = {
timer: null,
drainTimer: null,
cleanupWait: null,
queue: [],
queueHead: 0,
queuedDataBytes: 0,
waitingForDrain: false,
warnedBackpressure: false
}
this.pendingByClient.set(clientId, batch)
}
const last = batch.queue.at(-1)
if (last?.sessionId === sessionId) {
last.data += data
if (
event.kind === 'data' &&
last?.kind === 'data' &&
last.sessionId === event.sessionId &&
last.data.length + event.data.length <= STREAM_DATA_MAX_PAYLOAD_CHARS
) {
last.data += event.data
batch.queuedDataBytes += Buffer.byteLength(event.data, 'utf8')
} else {
batch.queue.push({ sessionId, data })
batch.queue.push(event)
if (event.kind === 'data') {
batch.queuedDataBytes += Buffer.byteLength(event.data, 'utf8')
}
}
if (!batch.timer) {
if (batch.queuedDataBytes > STREAM_DATA_MAX_QUEUED_BYTES) {
console.warn('[daemon] PTY stream socket queue exceeded limit', {
clientId,
queuedEvents: batch.queue.length - batch.queueHead,
queuedBytes: batch.queuedDataBytes
})
// Why: backpressure is on the renderer's single stream socket. Once that
// socket is unhealthy, per-PTY recovery cannot make progress until the
// client reconnects and reattaches its active sessions.
this.failStream(clientId)
return false
}
if (!batch.timer && !batch.waitingForDrain) {
batch.timer = setTimeout(() => this.flush(clientId), STREAM_DATA_BATCH_INTERVAL_MS)
}
return true
}
flush(clientId: string): void {
@ -56,23 +126,117 @@ export class DaemonStreamDataBatcher {
clearTimeout(batch.timer)
batch.timer = null
}
this.pendingByClient.delete(clientId)
const client = this.getClient(clientId)
if (!client?.streamSocket || client.streamSocket.destroyed) {
if (batch.waitingForDrain) {
return
}
for (const entry of batch.queue) {
client.streamSocket.write(
encodeNdjson({
type: 'event',
event: 'data',
sessionId: entry.sessionId,
payload: { data: entry.data }
})
)
const client = this.getClient(clientId)
if (!client?.streamSocket || client.streamSocket.destroyed) {
this.pendingByClient.delete(clientId)
return
}
const streamSocket = client.streamSocket
let flushedEvents = 0
while (batch.queueHead < batch.queue.length) {
const entry = batch.queue[batch.queueHead]!
batch.queueHead += 1
flushedEvents += 1
if (entry.kind === 'data') {
batch.queuedDataBytes -= Buffer.byteLength(entry.data, 'utf8')
}
const payload =
entry.kind === 'data'
? {
type: 'event',
event: 'data',
sessionId: entry.sessionId,
payload: { data: entry.data }
}
: {
type: 'event',
event: 'exit',
sessionId: entry.sessionId,
payload: { code: entry.code }
}
const ok = streamSocket.write(encodeNdjson(payload))
if (!ok) {
batch.waitingForDrain = true
this.compactQueue(batch)
if (
batch.queuedDataBytes >= STREAM_DATA_BACKPRESSURE_WARN_BYTES &&
!batch.warnedBackpressure
) {
batch.warnedBackpressure = true
console.warn('[daemon] PTY stream socket backpressure', {
clientId,
queuedEvents: batch.queue.length - batch.queueHead,
queuedBytes: batch.queuedDataBytes
})
}
let settled = false
const handleDrain = (): void => {
if (settled) {
return
}
cleanupWait()
const current = this.pendingByClient.get(clientId)
if (current !== batch) {
return
}
current.waitingForDrain = false
this.flush(clientId)
}
const handleTerminal = (): void => {
if (settled) {
return
}
cleanupWait()
if (this.pendingByClient.get(clientId) === batch) {
this.pendingByClient.delete(clientId)
}
}
const cleanupWait = (): void => {
settled = true
if (batch.drainTimer) {
clearTimeout(batch.drainTimer)
batch.drainTimer = null
}
batch.cleanupWait = null
streamSocket.removeListener('drain', handleDrain)
streamSocket.removeListener('close', handleTerminal)
streamSocket.removeListener('error', handleTerminal)
}
batch.cleanupWait = cleanupWait
batch.drainTimer = setTimeout(() => {
if (settled) {
return
}
console.warn('[daemon] PTY stream socket drain timed out', {
clientId,
queuedEvents: batch.queue.length - batch.queueHead,
queuedBytes: batch.queuedDataBytes
})
cleanupWait()
this.failStream(clientId)
}, STREAM_DATA_DRAIN_TIMEOUT_MS)
batch.drainTimer.unref?.()
streamSocket.once('close', handleTerminal)
streamSocket.once('error', handleTerminal)
streamSocket.once('drain', handleDrain)
return
}
if (
flushedEvents >= STREAM_DATA_MAX_EVENTS_PER_FLUSH &&
batch.queueHead < batch.queue.length
) {
this.compactQueue(batch)
batch.timer = setTimeout(() => this.flush(clientId), 0)
batch.timer.unref?.()
return
}
}
this.pendingByClient.delete(clientId)
}
clear(clientId?: string): void {
@ -82,10 +246,27 @@ export class DaemonStreamDataBatcher {
: [[clientId, this.pendingByClient.get(clientId)] as const]
for (const [id, batch] of batches) {
batch?.cleanupWait?.()
if (batch?.timer) {
clearTimeout(batch.timer)
}
if (batch?.drainTimer) {
clearTimeout(batch.drainTimer)
}
this.pendingByClient.delete(id)
}
}
private compactQueue(batch: PendingStreamDataBatch): void {
if (batch.queueHead === 0) {
return
}
batch.queue = batch.queue.slice(batch.queueHead)
batch.queueHead = 0
}
private failStream(clientId: string): void {
this.clear(clientId)
this.onStreamFailure(clientId)
}
}

View File

@ -2434,6 +2434,41 @@ describe('registerPtyHandlers', () => {
}
})
it('splits very large batched PTY output across IPC flush turns', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
mainWindow.webContents.send.mockClear()
const largeOutput = 'x'.repeat(9 * 64 * 1024)
mockProc.emitData(largeOutput)
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(8)
expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', {
id: spawnResult.id,
data: 'x'.repeat(64 * 1024)
})
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(9)
expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', {
id: spawnResult.id,
data: 'x'.repeat(64 * 1024)
})
} finally {
vi.useRealTimers()
}
})
it('batches combined pending output that exceeds the interactive size limit', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()

View File

@ -646,6 +646,12 @@ export function registerPtyHandlers(
// large output and non-interactive output must still use the batcher.
const INTERACTIVE_OUTPUT_WINDOW_MS = 100
const INTERACTIVE_OUTPUT_MAX_CHARS = 1024
const PTY_IPC_CHUNK_CHARS = 64 * 1024
const PTY_MAX_IPC_CHUNKS_PER_FLUSH = 8
const sendPtyData = (id: string, data: string): void => {
mainWindow.webContents.send('pty:data', { id, data })
}
const flushPendingData = (): void => {
flushTimer = null
@ -653,10 +659,30 @@ export function registerPtyHandlers(
pendingData.clear()
return
}
for (const [id, data] of pendingData) {
mainWindow.webContents.send('pty:data', { id, data })
let sentChunks = 0
while (pendingData.size > 0 && sentChunks < PTY_MAX_IPC_CHUNKS_PER_FLUSH) {
const entry = pendingData.entries().next().value
if (!entry) {
break
}
const [id, data] = entry
pendingData.delete(id)
if (data.length <= PTY_IPC_CHUNK_CHARS) {
sendPtyData(id, data)
} else {
sendPtyData(id, data.slice(0, PTY_IPC_CHUNK_CHARS))
// Why: a single noisy PTY can otherwise serialize megabytes of IPC in
// one main-process turn. Requeue the remainder behind other PTYs so
// active terminal redraws and control IPC keep getting turns.
pendingData.set(id, data.slice(PTY_IPC_CHUNK_CHARS))
}
sentChunks++
}
if (pendingData.size > 0) {
flushTimer = setTimeout(flushPendingData, PTY_BATCH_INTERVAL_MS)
}
pendingData.clear()
}
const clearFlushTimerIfIdle = (): void => {
@ -710,10 +736,7 @@ export function registerPtyHandlers(
clearFlushTimerIfIdle()
// Why: agent TUIs redraw small prompt regions after every keystroke.
// Waiting for the throughput batch timer adds visible input latency.
mainWindow.webContents.send('pty:data', {
id: payload.id,
data: nextData
})
sendPtyData(payload.id, nextData)
return
}
pendingData.set(payload.id, nextData)
@ -734,7 +757,7 @@ export function registerPtyHandlers(
// tears down the terminal on pty:exit before the batch timer fires.
const remaining = pendingData.get(payload.id)
if (remaining) {
mainWindow.webContents.send('pty:data', { id: payload.id, data: remaining })
sendPtyData(payload.id, remaining)
pendingData.delete(payload.id)
}
lastInputAtByPty.delete(payload.id)

View File

@ -990,7 +990,51 @@ describe('connectPanePty', () => {
}
})
it('writes visible split-pane PTY bytes immediately even when the tab is not active', async () => {
it('queues visible inactive split-pane PTY bytes so active pane input stays responsive', async () => {
const pendingTimeouts: (() => void)[] = []
const originalSetTimeout = globalThis.setTimeout
globalThis.setTimeout = vi.fn((fn: () => void) => {
pendingTimeouts.push(fn)
return 999 as unknown as ReturnType<typeof setTimeout>
}) as unknown as typeof setTimeout
try {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
transport.connect.mockImplementation(
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
}
)
transportFactoryQueue.push(transport)
const pane = createPane(1)
const manager = createManager(2)
manager.getActivePane.mockReturnValue({ id: 2 })
const deps = createDeps({
isVisibleRef: { current: true }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
capturedDataCallback.current?.('visible split output\r\n')
expect(pane.terminal.write).not.toHaveBeenCalledWith('visible split output\r\n')
for (const fn of pendingTimeouts) {
fn()
}
expect(pane.terminal.write).toHaveBeenCalledWith('visible split output\r\n')
} finally {
globalThis.setTimeout = originalSetTimeout
}
})
it('writes active visible split-pane PTY bytes immediately', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
@ -1001,9 +1045,9 @@ describe('connectPanePty', () => {
transportFactoryQueue.push(transport)
const pane = createPane(1)
const manager = createManager(1)
const manager = createManager(2)
manager.getActivePane.mockReturnValue({ id: 1 })
const deps = createDeps({
isActiveRef: { current: false },
isVisibleRef: { current: true }
})
@ -1011,9 +1055,9 @@ describe('connectPanePty', () => {
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
capturedDataCallback.current?.('visible split output\r\n')
capturedDataCallback.current?.('active split output\r\n')
expect(pane.terminal.write).toHaveBeenCalledWith('visible split output\r\n')
expect(pane.terminal.write).toHaveBeenCalledWith('active split output\r\n')
})
it('marks panes that receive Arabic output for DOM rendering', async () => {

View File

@ -877,11 +877,12 @@ export function connectPanePty(
if (terminalOutputPrefersDomRenderer(data)) {
manager.markPaneHasComplexScriptOutput(pane.id)
}
// Why: visibility is the right gate — split-pane layouts have multiple
// visible-but-inactive panes whose output the user is watching. Only
// hidden panes (background tabs) should be throttled.
// Why: the active split pane owns keyboard latency. Visible inactive
// panes still drain, but through the shared scheduler so a build log in
// another split cannot monopolize xterm writes while the user types.
const activePaneId = manager.getActivePane()?.id ?? pane.id
writeTerminalOutput(pane.terminal, data, {
foreground: deps.isVisibleRef.current
foreground: deps.isVisibleRef.current && activePaneId === pane.id
})
if (pendingStartupCommand) {

View File

@ -192,6 +192,46 @@ describe('agent status tool + assistant fields', () => {
expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1)
})
it('throttles unchanged fresh same-state heartbeats to avoid status-map churn', () => {
vi.useFakeTimers()
const store = createTestStore()
store
.getState()
.setAgentStatus(
'tab-1:1',
{ state: 'working', prompt: 'p1', agentType: 'claude', toolName: 'Read' },
'claude',
{ updatedAt: 1_000, stateStartedAt: 1_000 }
)
const firstMap = store.getState().agentStatusByPaneKey
const firstEntry = firstMap['tab-1:1']
store
.getState()
.setAgentStatus(
'tab-1:1',
{ state: 'working', prompt: 'p1', agentType: 'claude', toolName: 'Read' },
'claude',
{ updatedAt: 1_500, stateStartedAt: 1_000 }
)
expect(store.getState().agentStatusByPaneKey).toBe(firstMap)
expect(store.getState().agentStatusByPaneKey['tab-1:1']).toBe(firstEntry)
expect(store.getState().agentStatusByPaneKey['tab-1:1'].updatedAt).toBe(1_000)
store
.getState()
.setAgentStatus(
'tab-1:1',
{ state: 'working', prompt: 'p1', agentType: 'claude', toolName: 'Read' },
'claude',
{ updatedAt: 2_000, stateStartedAt: 1_000 }
)
expect(store.getState().agentStatusByPaneKey).not.toBe(firstMap)
expect(store.getState().agentStatusByPaneKey['tab-1:1'].updatedAt).toBe(2_000)
})
it('bumps global epochs when a stale same-state entry refreshes', () => {
vi.useFakeTimers()
const store = createTestStore()

View File

@ -123,6 +123,27 @@ function paneKeyMatchesAnyTabPrefix(paneKey: string, tabPrefixes: string[]): boo
return false
}
const UNCHANGED_AGENT_STATUS_UPDATE_MIN_INTERVAL_MS = 1_000
function isUnchangedAgentStatusHeartbeat(
previous: AgentStatusEntry,
next: AgentStatusEntry
): boolean {
return (
previous.state === next.state &&
previous.prompt === next.prompt &&
previous.stateStartedAt === next.stateStartedAt &&
previous.agentType === next.agentType &&
previous.paneKey === next.paneKey &&
previous.terminalTitle === next.terminalTitle &&
previous.stateHistory === next.stateHistory &&
previous.toolName === next.toolName &&
previous.toolInput === next.toolInput &&
previous.lastAssistantMessage === next.lastAssistantMessage &&
previous.interrupted === next.interrupted
)
}
function pruneMigrationUnsupportedEntries(
entries: Record<string, MigrationUnsupportedPtyEntry>,
predicate: (entry: MigrationUnsupportedPtyEntry) => boolean
@ -284,6 +305,23 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
// status ping would churn that map reference and force spurious
// re-renders in any subscriber selecting on it.
const hasSuppressor = paneKey in s.retentionSuppressedPaneKeys
const hasMigrationUnsupportedForPaneKey = Object.values(s.migrationUnsupportedByPtyId).some(
(entry) => entry.paneKey === paneKey
)
// Why: Codex/Claude hook heartbeats can arrive many times per second
// with only `updatedAt` changed. Rewriting the whole status map for
// those pings wakes sidebar/runtime subscribers without changing what
// the user sees, so keep freshness accurate at human-scale cadence.
if (
existing &&
!sortRelevantChange &&
!hasSuppressor &&
!hasMigrationUnsupportedForPaneKey &&
updatedAt - existing.updatedAt < UNCHANGED_AGENT_STATUS_UPDATE_MIN_INTERVAL_MS &&
isUnchangedAgentStatusHeartbeat(existing, entry)
) {
return s
}
let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys
if (hasSuppressor) {
nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys }

View File

@ -15,7 +15,13 @@ import {
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import { getTerminalContent, waitForActiveTerminalManager } from './helpers/terminal'
import {
getTerminalContent,
splitActiveTerminalPane,
waitForActiveTerminalManager,
waitForPaneCount,
waitForPaneIdentitySnapshot
} from './helpers/terminal'
type SchedulerDebugSnapshot = {
backgroundEnqueueCount: number
@ -249,4 +255,75 @@ test.describe('Terminal output scheduler', () => {
})
.toBe(true)
})
test('visible inactive split-pane output uses the shared drain @headful', async ({
orcaPage
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await waitForPaneCount(orcaPage, 1, 30_000)
await splitActiveTerminalPane(orcaPage, 'horizontal')
const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 2)
const activePane = snapshot.panes[0]
const inactivePane = snapshot.panes[1]
if (!activePane.ptyId || !inactivePane.ptyId) {
throw new Error('Split pane PTY ids were unavailable')
}
await orcaPage.evaluate(
({ tabId, paneId }) => {
const manager = window.__paneManagers?.get(tabId)
if (!manager) {
throw new Error('Active terminal PaneManager is not mounted')
}
// Why: the active split pane owns keyboard latency even though both
// split panes are visible in this headful repro.
manager.setActivePane(paneId, { focus: true })
},
{ tabId: snapshot.tabId, paneId: activePane.numericPaneId }
)
await resetSchedulerDebug(orcaPage)
const runId = Date.now()
const activeMarker = `ACTIVE_SPLIT_SCHED_${runId}`
const inactiveMarker = `INACTIVE_SPLIT_SCHED_${runId}`
await sendPtyCommands(orcaPage, [
{
ptyId: inactivePane.ptyId,
command: nodeConsoleCommand(`'x'.repeat(120000) + ':${inactiveMarker}'`)
},
{
ptyId: activePane.ptyId,
command: nodeConsoleCommand(`'${activeMarker}'`)
}
])
await expect
.poll(async () => (await getTerminalContent(orcaPage)).includes(activeMarker), {
timeout: 5_000,
message: 'Active split pane did not render foreground output during inactive burst'
})
.toBe(true)
await expect
.poll(async () => (await getSchedulerDebug(orcaPage)).backgroundEnqueueCount, {
timeout: 5_000,
message: 'Visible inactive split-pane output bypassed the shared scheduler'
})
.toBeGreaterThanOrEqual(1)
await expect
.poll(async () => (await getSchedulerDebug(orcaPage)).backgroundWriteCount, {
timeout: 10_000,
message: 'Visible inactive split-pane output did not drain from the shared scheduler'
})
.toBeGreaterThanOrEqual(1)
const debug = await getSchedulerDebug(orcaPage)
expect(debug.foregroundWriteCount).toBeGreaterThan(0)
})
})