diff --git a/src/services/AgentSummary/__tests__/agentSummary.test.ts b/src/services/AgentSummary/__tests__/agentSummary.test.ts index 421219a94..e27b36593 100644 --- a/src/services/AgentSummary/__tests__/agentSummary.test.ts +++ b/src/services/AgentSummary/__tests__/agentSummary.test.ts @@ -109,6 +109,10 @@ describe('startAgentSummarization', () => { lastTimerHandle = undefined }) + function expectDebugLogContaining(fragment: string): void { + expect(debugLogs.some(message => message.includes(fragment))).toBe(true) + } + test('summarizes bounded transcript once and skips unchanged fingerprints', async () => { handle = startTestSummarization() @@ -157,7 +161,7 @@ describe('startAgentSummarization', () => { expect(forkCalls).toEqual([]) expect(updateCalls).toEqual([]) - expect(debugLogs).toContain( + expectDebugLogContaining( '[AgentSummary] Skipping summary for task-1: no bounded context available', ) }) @@ -171,7 +175,7 @@ describe('startAgentSummarization', () => { expect(forkCalls).toEqual([]) expect(updateCalls).toEqual([]) - expect(debugLogs).toContain( + expectDebugLogContaining( '[AgentSummary] Skipping summary for task-1: not enough messages (2)', ) }) @@ -188,9 +192,7 @@ describe('startAgentSummarization', () => { expect(forkCalls).toEqual([]) expect(updateCalls).toEqual([]) - expect(debugLogs).toContain( - '[AgentSummary] Skipping summary — poor mode active', - ) + expectDebugLogContaining('[AgentSummary] Skipping summary — poor mode active') expect(scheduledCount).toBe(initialScheduledCount + 1) expect(lastTimerHandle).not.toBe(initialTimerHandle) }) @@ -220,9 +222,7 @@ describe('startAgentSummarization', () => { handle.stop() - expect(debugLogs).toContain( - '[AgentSummary] Stopping summarization for task-1', - ) + expectDebugLogContaining('[AgentSummary] Stopping summarization for task-1') expect(clearedHandles).toEqual([pendingHandle]) }) }) diff --git a/src/utils/__tests__/teammateMailbox.test.ts b/src/utils/__tests__/teammateMailbox.test.ts index d3dc36e54..b036b9ce8 100644 --- a/src/utils/__tests__/teammateMailbox.test.ts +++ b/src/utils/__tests__/teammateMailbox.test.ts @@ -365,7 +365,11 @@ describe('teammate mailbox retention', () => { if (code === undefined) { throw new Error('Expected filesystem errno code') } - expect(['EISDIR', 'EPERM', 'EACCES']).toContain(code) + const expectedCodes = + process.platform === 'win32' + ? ['EISDIR', 'EPERM', 'EACCES'] + : ['EISDIR'] + expect(expectedCodes).toContain(code) expect((await stat(inboxPath)).isDirectory()).toBe(true) }) diff --git a/src/utils/__tests__/udsMessaging.test.ts b/src/utils/__tests__/udsMessaging.test.ts index bebaa495d..8ad179d45 100644 --- a/src/utils/__tests__/udsMessaging.test.ts +++ b/src/utils/__tests__/udsMessaging.test.ts @@ -275,7 +275,7 @@ describe('UDS inbox retention', () => { '../udsClient.js' ) - const error = await sendToUdsSocket(path, 'hello', 50).then( + const error = await sendToUdsSocket(path, 'hello', 200).then( () => undefined, err => err, ) @@ -301,6 +301,75 @@ describe('UDS inbox retention', () => { } }) + test('connectToPeer reports connection failures as peer connection errors', async () => { + const path = socketPath('uds-connect-error') + const { connectToPeer, UdsPeerConnectionError } = await import( + '../udsClient.js' + ) + + const error = await connectToPeer(path, () => { + throw new Error('Unexpected post-connect socket error') + }).then( + () => undefined, + err => err, + ) + + expect(error).toBeInstanceOf(UdsPeerConnectionError) + if (!(error instanceof UdsPeerConnectionError)) { + throw new Error('Expected UDS peer connection error') + } + expect(error.socketPath).toBe(path) + }) + + test('connectToPeer leaves connected socket lifecycle to the caller', async () => { + const path = socketPath('uds-connect-lifecycle') + if (process.platform !== 'win32') { + await mkdir(dirname(path), { recursive: true }) + } + + const sockets = new Set() + const receiver = createServer(socket => { + sockets.add(socket) + socket.on('close', () => { + sockets.delete(socket) + }) + }) + await new Promise((resolve, reject) => { + receiver.on('error', reject) + receiver.listen(path, () => resolve()) + }) + + let client: Socket | undefined + const socketErrors: Error[] = [] + try { + const { connectToPeer } = await import('../udsClient.js') + client = await connectToPeer( + path, + error => { + socketErrors.push(error) + }, + 1000, + ) + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(client.destroyed).toBe(false) + expect(client.listenerCount('error')).toBe(1) + + const socketError = new Error('post-connect failure') + client.emit('error', socketError) + expect(socketErrors).toEqual([socketError]) + } finally { + client?.destroy() + for (const socket of sockets) { + socket.destroy() + } + await closeServer(receiver) + if (process.platform !== 'win32') { + await unlink(path).catch(() => undefined) + } + } + }) + test('sendUdsMessage fails closed before connecting without an auth token', async () => { await expect( sendUdsMessage(socketPath('no-auth-token'), { type: 'text', data: 'x' }), diff --git a/src/utils/udsClient.ts b/src/utils/udsClient.ts index 54d88f7fc..c0a32f721 100644 --- a/src/utils/udsClient.ts +++ b/src/utils/udsClient.ts @@ -266,17 +266,48 @@ export async function sendToUdsSocket( /** * Connect to a peer and return the raw socket for bidirectional communication. - * The caller is responsible for managing the connection lifecycle. + * The caller owns the post-connect lifecycle through onSocketError, which is + * attached before the Promise resolves so peer socket errors cannot be + * swallowed or surface through a listener handoff window. + * Pre-connect failures reject with UdsPeerConnectionError. + * This only opens the transport; callers still own any capability handshake. */ -export function connectToPeer(socketPath: string): Promise { +export function connectToPeer( + socketPath: string, + onSocketError: (error: Error) => void, + timeoutMs = 5000, +): Promise { return new Promise((resolve, reject) => { - const conn = createConnection(socketPath, () => { + const conn = createConnection(socketPath) + let settled = false + const timeout = setTimeout( + fail, + timeoutMs, + new Error('Connection timed out'), + ) + function cleanupListeners(): void { + clearTimeout(timeout) + conn.off('error', fail) + } + function fail(cause: unknown): void { + if (settled) { + return + } + settled = true + cleanupListeners() + conn.destroy() + reject(new UdsPeerConnectionError(socketPath, cause)) + } + conn.once('connect', () => { + if (settled) { + return + } + settled = true + cleanupListeners() + conn.on('error', onSocketError) resolve(conn) }) - conn.on('error', reject) - conn.setTimeout(5000, () => { - conn.destroy(new Error('Connection timed out')) - }) + conn.on('error', fail) }) }