fix: close peer socket listener handoff window

CodeRabbit and Claude review found that documenting caller-owned raw socket errors still left a Promise handoff window and a stale timeout-listener risk. The peer connection API now requires a caller error handler and installs it before resolving, while cleanup removes internal error and timeout listeners on every path.

Constraint: Keep the fix precise to PR #375 review feedback and avoid warning suppression or fallback behavior.
Rejected: Leave the behavior documented only | still permits an unhandled socket error window between resolve and caller listener attachment.
Rejected: Keep a no-op internal error listener | would silently swallow caller-owned socket errors.
Confidence: high
Scope-risk: narrow
Directive: Do not add raw connectToPeer callers without providing a real onSocketError handler and capability handshake.
Tested: bun test src/utils/__tests__/udsMessaging.test.ts src/services/AgentSummary/__tests__/agentSummary.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: bun audit
Not-tested: Manual external ACP peer runtime beyond repository tests.
This commit is contained in:
unraid
2026-04-27 17:20:51 +08:00
parent a5ede237f0
commit a90c16431b
3 changed files with 48 additions and 17 deletions

View File

@@ -161,7 +161,9 @@ describe('startAgentSummarization', () => {
expect(forkCalls).toEqual([])
expect(updateCalls).toEqual([])
expectDebugLogContaining('no bounded context available')
expectDebugLogContaining(
'[AgentSummary] Skipping summary for task-1: no bounded context available',
)
})
test('skips summarization before building context when transcript is too short', async () => {
@@ -173,7 +175,9 @@ describe('startAgentSummarization', () => {
expect(forkCalls).toEqual([])
expect(updateCalls).toEqual([])
expectDebugLogContaining('not enough messages (2)')
expectDebugLogContaining(
'[AgentSummary] Skipping summary for task-1: not enough messages (2)',
)
})
test('skips and reschedules while poor mode is active', async () => {
@@ -188,7 +192,7 @@ describe('startAgentSummarization', () => {
expect(forkCalls).toEqual([])
expect(updateCalls).toEqual([])
expectDebugLogContaining('poor mode active')
expectDebugLogContaining('[AgentSummary] Skipping summary — poor mode active')
expect(scheduledCount).toBe(initialScheduledCount + 1)
expect(lastTimerHandle).not.toBe(initialTimerHandle)
})
@@ -218,7 +222,7 @@ describe('startAgentSummarization', () => {
handle.stop()
expectDebugLogContaining('Stopping summarization for task-1')
expectDebugLogContaining('[AgentSummary] Stopping summarization for task-1')
expect(clearedHandles).toEqual([pendingHandle])
})
})

View File

@@ -307,7 +307,9 @@ describe('UDS inbox retention', () => {
'../udsClient.js'
)
const error = await connectToPeer(path).then(
const error = await connectToPeer(path, () => {
throw new Error('Unexpected post-connect socket error')
}).then(
() => undefined,
err => err,
)
@@ -338,13 +340,25 @@ describe('UDS inbox retention', () => {
})
let client: Socket | undefined
const socketErrors: Error[] = []
try {
const { connectToPeer } = await import('../udsClient.js')
client = await connectToPeer(path, 1000)
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(0)
expect(client.listenerCount('error')).toBe(1)
expect(client.listenerCount('timeout')).toBe(0)
const socketError = new Error('post-connect failure')
client.emit('error', socketError)
expect(socketErrors).toEqual([socketError])
} finally {
client?.destroy()
for (const socket of sockets) {

View File

@@ -266,36 +266,49 @@ 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, including
* attaching an 'error' listener immediately after the Promise resolves. This
* function detaches its internal error listener on successful connect so
* caller-owned socket errors are not silently swallowed.
* 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,
onSocketError: (error: Error) => void,
timeoutMs = 5000,
): Promise<Socket> {
return new Promise<Socket>((resolve, reject) => {
const conn = createConnection(socketPath)
let settled = false
const fail = (cause: unknown) => {
const onTimeout = () => {
fail(new Error('Connection timed out'))
}
function cleanupListeners(): void {
conn.setTimeout(0)
conn.off('error', fail)
conn.off('timeout', onTimeout)
}
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
conn.setTimeout(0)
conn.off('error', fail)
cleanupListeners()
conn.on('error', onSocketError)
resolve(conn)
})
conn.on('error', fail)
conn.setTimeout(timeoutMs, () => {
fail(new Error('Connection timed out'))
})
conn.once('timeout', onTimeout)
conn.setTimeout(timeoutMs)
})
}