Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions packages/worker/src/remote-connector/session.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,3 +292,116 @@ test('remote connector session lifecycle across restore, snapshot, heartbeat, cl
tools: [],
})
})

test('tools/list_changed snapshot refresh failures log a warning without Sentry', async () => {
consoleWarn.mockImplementation(() => {})
const { session, state, persistedEntries } =
await createRemoteConnectorSession({
storedState: {
persisted: {
connectorId: 'home',
connectedAt: '2026-04-26T05:00:00.000Z',
lastSeenAt: '2026-04-26T05:01:00.000Z',
},
tools: [{ name: 'bond_shade_set_position' }],
},
webSockets: [{} as WebSocket],
})
// Drop the socket before tools/list so refresh fails immediately (same
// soft-fail path as an RPC timeout against a stalled home connector).
state.getWebSockets.mockReturnValue([])

await session.webSocketMessage(
{} as WebSocket,
JSON.stringify({
type: 'connector.jsonrpc',
message: {
jsonrpc: '2.0',
method: 'notifications/tools/list_changed',
},
}),
)

expect(captureMessageMock).not.toHaveBeenCalled()
expect(consoleWarn).toHaveBeenCalledWith(
'Remote connector tools snapshot refresh failed on tools/list_changed. connectorId=home error=No remote connector is connected.',
)
expect(persistedEntries.get('remote-connector-session-state')).toMatchObject({
persisted: {
connectorId: 'home',
connectedAt: '2026-04-26T05:00:00.000Z',
},
tools: [],
})
})

test('malformed tools/list snapshot responses still report to Sentry', async () => {
const sent: Array<string> = []
const socket = {
send: (payload: string) => {
sent.push(payload)
},
} as unknown as WebSocket
const { session, persistedEntries } = await createRemoteConnectorSession({
storedState: {
persisted: {
connectorId: 'home',
connectedAt: '2026-04-26T05:00:00.000Z',
lastSeenAt: '2026-04-26T05:01:00.000Z',
},
tools: [{ name: 'bond_shade_set_position' }],
},
webSockets: [socket],
})

const refresh = session.webSocketMessage(
socket,
JSON.stringify({
type: 'connector.jsonrpc',
message: {
jsonrpc: '2.0',
method: 'notifications/tools/list_changed',
},
}),
)

await vi.waitFor(() => {
expect(sent.length).toBeGreaterThan(0)
})
const request = JSON.parse(sent[0]!) as {
type: string
message: { id: string; method: string }
}
expect(request).toMatchObject({
type: 'connector.jsonrpc',
message: { method: 'tools/list' },
})

await session.webSocketMessage(
socket,
JSON.stringify({
type: 'connector.jsonrpc',
message: {
jsonrpc: '2.0',
id: request.message.id,
result: null,
},
}),
)
await refresh

expect(captureMessageMock).toHaveBeenCalledWith(
'Remote connector session message handler threw.',
expect.objectContaining({
level: 'error',
extra: expect.objectContaining({
connectorId: 'home',
messageType: 'connector.jsonrpc',
error: 'Malformed tools/list result.',
}),
}),
)
expect(persistedEntries.get('remote-connector-session-state')).toMatchObject({
tools: [{ name: 'bond_shade_set_position' }],
})
})
99 changes: 70 additions & 29 deletions packages/worker/src/remote-connector/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ const connectorTag = 'connector'
const stateStorageKey = 'remote-connector-session-state'
const rpcTimeoutMs = 15_000

const remoteConnectorToolsListRpcErrorName = 'RemoteConnectorToolsListRpcError'

function isExpectedToolsSnapshotRefreshFailure(error: unknown) {
if (
error instanceof Error &&
error.name === remoteConnectorToolsListRpcErrorName
) {
return true
}
const message = getErrorMessage(error)
return (
message === 'No remote connector is connected.' ||
message.startsWith('Timed out waiting for remote connector response to ') ||
message.includes(' before RPC response.')
)
}

type PendingRpcRequest = {
resolve: (message: RemoteConnectorJsonRpcResponse) => void
reject: (error: Error) => void
Expand Down Expand Up @@ -502,18 +519,10 @@ class RemoteConnectorSessionBase extends DurableObject<Env> {
try {
await this.refreshToolsSnapshot()
} catch (error) {
this.stateSnapshot.tools = []
this.captureSessionMessage(
'Remote connector tools snapshot refresh failed after websocket hello.',
{
level: 'error',
extra: {
connectorId: this.stateSnapshot.persisted.connectorId,
error: getErrorMessage(error),
},
},
)
await this.persistState()
await this.handleToolsSnapshotRefreshFailure({
phase: 'after websocket hello',
error,
})
}
}

Expand All @@ -538,32 +547,64 @@ class RemoteConnectorSessionBase extends DurableObject<Env> {
try {
await this.refreshToolsSnapshot()
} catch (error) {
this.stateSnapshot.tools = []
this.captureSessionMessage(
'Remote connector tools snapshot refresh failed.',
{
level: 'error',
extra: {
connectorId: this.stateSnapshot.persisted.connectorId,
error: getErrorMessage(error),
},
},
)
await this.persistState()
return
await this.handleToolsSnapshotRefreshFailure({
phase: 'on tools/list_changed',
error,
})
}
}
}

private async handleToolsSnapshotRefreshFailure(input: {
phase: 'after websocket hello' | 'on tools/list_changed'
error: unknown
}) {
if (!isExpectedToolsSnapshotRefreshFailure(input.error)) {
// Malformed tools/list payloads, storage failures, and other
// implementation bugs should still open Sentry via the outer
// message-handler catch — do not clear a still-valid tool cache.
throw input.error
}
// Timeouts, disconnects mid-RPC, and "not connected" are expected
// remote-connector lifecycle noise (same class as websocket closes).
// Soft-fail by clearing tools; keep an ops log line and do not open
// Sentry issues. Restore the in-memory cache if persistence fails so
// later reads are not left empty while Sentry captures the storage bug.
const previousTools = this.stateSnapshot.tools
this.stateSnapshot.tools = []
console.warn(
`Remote connector tools snapshot refresh failed ${input.phase}. connectorId=${this.stateSnapshot.persisted.connectorId ?? 'null'} error=${getErrorMessage(input.error)}`,
)
try {
await this.persistState()
} catch (persistError) {
this.stateSnapshot.tools = previousTools
throw persistError
}
}

private async refreshToolsSnapshot() {
const response = await this.sendRpcRequest('tools/list', {})
if ('error' in response) {
throw new Error(response.error.message)
const error = new Error(response.error.message)
error.name = remoteConnectorToolsListRpcErrorName
throw error
}
const result = response.result
if (
result === null ||
typeof result !== 'object' ||
Array.isArray(result)
) {
throw new Error('Malformed tools/list result.')
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const result = response.result as {
tools?: Array<RemoteConnectorSnapshot['tools'][number]>
const tools = (result as { tools?: unknown }).tools
if (tools !== undefined && !Array.isArray(tools)) {
throw new Error('Malformed tools/list tools.')
}
this.stateSnapshot.tools = result.tools ?? []
this.stateSnapshot.tools =
(tools as Array<RemoteConnectorSnapshot['tools'][number]> | undefined) ??
[]
this.stateSnapshot.persisted.lastSeenAt = new Date().toISOString()
await this.persistState()
}
Expand Down
Loading