fix: keep UDS peer failures structured

CodeRabbit and Claude cross-review identified that timeout and raw peer connection failures should share one observable error contract. UDS peer failures now use UdsPeerConnectionError consistently, and connectToPeer hands the socket lifecycle back to the caller after a successful connection instead of retaining an internal timeout or error listener.

The tests cover the real socket paths with capability files, timeout behavior, connection failure structure, post-connect listener handoff, AgentSummary rescheduling observations, and platform-specific mailbox directory errno handling.

Constraint: Preserve the 5000ms production timeout default while allowing tests to exercise timeout paths quickly.

Rejected: Suppress CodeRabbit warnings in tests | would hide the real timeout/error contract gap.

Rejected: Keep connectToPeer post-connect error listener | it would silently swallow caller-owned socket errors.

Confidence: high

Scope-risk: narrow

Directive: Keep UDS send/connect timeout and socket-error paths on the same structured peer error contract.

Tested: bun test src/utils/__tests__/udsMessaging.test.ts src/services/AgentSummary/__tests__/agentSummary.test.ts src/utils/__tests__/teammateMailbox.test.ts

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun run test:all

Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage

Tested: bun run build

Tested: bun run build:vite

Tested: omx ask claude simplify review artifact .omx/artifacts/claude-review-only-cross-check-for-pr-374-on-branch-codex-codecov-r-2026-04-27T08-17-47-309Z.md

Tested: omx ask claude security review artifact .omx/artifacts/claude-security-review-cross-check-for-pr-374-current-working-tree--2026-04-27T08-26-54-079Z.md

Not-tested: GitHub-hosted CodeRabbit refresh until pushed.
This commit is contained in:
unraid
2026-04-27 16:31:02 +08:00
parent 3305da0d49
commit f3f8c9339b
4 changed files with 91 additions and 19 deletions

View File

@@ -109,6 +109,10 @@ describe('startAgentSummarization', () => {
lastTimerHandle = undefined 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 () => { test('summarizes bounded transcript once and skips unchanged fingerprints', async () => {
handle = startTestSummarization() handle = startTestSummarization()
@@ -157,9 +161,7 @@ describe('startAgentSummarization', () => {
expect(forkCalls).toEqual([]) expect(forkCalls).toEqual([])
expect(updateCalls).toEqual([]) expect(updateCalls).toEqual([])
expect(debugLogs).toContain( expectDebugLogContaining('no bounded context available')
'[AgentSummary] Skipping summary for task-1: no bounded context available',
)
}) })
test('skips summarization before building context when transcript is too short', async () => { test('skips summarization before building context when transcript is too short', async () => {
@@ -171,9 +173,7 @@ describe('startAgentSummarization', () => {
expect(forkCalls).toEqual([]) expect(forkCalls).toEqual([])
expect(updateCalls).toEqual([]) expect(updateCalls).toEqual([])
expect(debugLogs).toContain( expectDebugLogContaining('not enough messages (2)')
'[AgentSummary] Skipping summary for task-1: not enough messages (2)',
)
}) })
test('skips and reschedules while poor mode is active', async () => { test('skips and reschedules while poor mode is active', async () => {
@@ -188,9 +188,7 @@ describe('startAgentSummarization', () => {
expect(forkCalls).toEqual([]) expect(forkCalls).toEqual([])
expect(updateCalls).toEqual([]) expect(updateCalls).toEqual([])
expect(debugLogs).toContain( expectDebugLogContaining('poor mode active')
'[AgentSummary] Skipping summary — poor mode active',
)
expect(scheduledCount).toBe(initialScheduledCount + 1) expect(scheduledCount).toBe(initialScheduledCount + 1)
expect(lastTimerHandle).not.toBe(initialTimerHandle) expect(lastTimerHandle).not.toBe(initialTimerHandle)
}) })
@@ -220,9 +218,7 @@ describe('startAgentSummarization', () => {
handle.stop() handle.stop()
expect(debugLogs).toContain( expectDebugLogContaining('Stopping summarization for task-1')
'[AgentSummary] Stopping summarization for task-1',
)
expect(clearedHandles).toEqual([pendingHandle]) expect(clearedHandles).toEqual([pendingHandle])
}) })
}) })

View File

@@ -365,7 +365,11 @@ describe('teammate mailbox retention', () => {
if (code === undefined) { if (code === undefined) {
throw new Error('Expected filesystem errno code') 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) expect((await stat(inboxPath)).isDirectory()).toBe(true)
}) })

View File

@@ -275,7 +275,7 @@ describe('UDS inbox retention', () => {
'../udsClient.js' '../udsClient.js'
) )
const error = await sendToUdsSocket(path, 'hello', 50).then( const error = await sendToUdsSocket(path, 'hello', 200).then(
() => undefined, () => undefined,
err => err, err => err,
) )
@@ -301,6 +301,62 @@ 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).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<Socket>()
const receiver = createServer(socket => {
sockets.add(socket)
socket.on('close', () => {
sockets.delete(socket)
})
})
await new Promise<void>((resolve, reject) => {
receiver.on('error', reject)
receiver.listen(path, () => resolve())
})
let client: Socket | undefined
try {
const { connectToPeer } = await import('../udsClient.js')
client = await connectToPeer(path, 50)
await new Promise(resolve => setTimeout(resolve, 100))
expect(client.destroyed).toBe(false)
expect(client.listenerCount('error')).toBe(0)
} 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 () => { test('sendUdsMessage fails closed before connecting without an auth token', async () => {
await expect( await expect(
sendUdsMessage(socketPath('no-auth-token'), { type: 'text', data: 'x' }), sendUdsMessage(socketPath('no-auth-token'), { type: 'text', data: 'x' }),

View File

@@ -268,14 +268,30 @@ export async function sendToUdsSocket(
* Connect to a peer and return the raw socket for bidirectional communication. * Connect to a peer and return the raw socket for bidirectional communication.
* The caller is responsible for managing the connection lifecycle. * The caller is responsible for managing the connection lifecycle.
*/ */
export function connectToPeer(socketPath: string): Promise<Socket> { export function connectToPeer(
socketPath: string,
timeoutMs = 5000,
): Promise<Socket> {
return new Promise<Socket>((resolve, reject) => { return new Promise<Socket>((resolve, reject) => {
const conn = createConnection(socketPath, () => { const conn = createConnection(socketPath)
let settled = false
const fail = (cause: unknown) => {
if (settled) {
return
}
settled = true
conn.destroy()
reject(new UdsPeerConnectionError(socketPath, cause))
}
conn.once('connect', () => {
settled = true
conn.setTimeout(0)
conn.off('error', fail)
resolve(conn) resolve(conn)
}) })
conn.on('error', reject) conn.on('error', fail)
conn.setTimeout(5000, () => { conn.setTimeout(timeoutMs, () => {
conn.destroy(new Error('Connection timed out')) fail(new Error('Connection timed out'))
}) })
}) })
} }