feat: implement daemon connectivity updates and relay protocol versioning
This commit is contained in:
@@ -83,7 +83,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -166,12 +166,177 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('does not reconnect after close when ensureConnected is called', async () => {
|
||||
const logger = createMockLogger()
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
})
|
||||
clients.push(client)
|
||||
|
||||
const connectPromise = client.connect()
|
||||
mock.triggerOpen()
|
||||
await connectPromise
|
||||
expect(client.getConnectionState().status).toBe('connected')
|
||||
|
||||
await client.close()
|
||||
expect(client.getConnectionState().status).toBe('disposed')
|
||||
|
||||
client.ensureConnected()
|
||||
expect(client.getConnectionState().status).toBe('disposed')
|
||||
})
|
||||
|
||||
test('transitions out of connecting when connect timeout elapses', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const logger = createMockLogger()
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
connectTimeoutMs: 100,
|
||||
transportFactory: () => mock.transport,
|
||||
})
|
||||
clients.push(client)
|
||||
|
||||
const pendingConnect = client.connect().then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error })
|
||||
)
|
||||
expect(client.getConnectionState().status).toBe('connecting')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120)
|
||||
const result = await pendingConnect
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBeInstanceOf(Error)
|
||||
expect((result.error as Error).message).toContain('Connection timed out')
|
||||
}
|
||||
expect(client.getConnectionState().status).toBe('disconnected')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
test('reconnects after relay close with replaced-by-new-connection reason', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const logger = createMockLogger()
|
||||
const first = createMockTransport()
|
||||
const second = createMockTransport()
|
||||
const transports = [first, second]
|
||||
let transportIndex = 0
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://relay.test/ws?role=client&serverId=srv_test&clientId=clsk_test&v=2',
|
||||
logger,
|
||||
reconnect: {
|
||||
enabled: true,
|
||||
baseDelayMs: 5,
|
||||
maxDelayMs: 5,
|
||||
},
|
||||
transportFactory: () => {
|
||||
const next = transports[Math.min(transportIndex, transports.length - 1)]
|
||||
transportIndex += 1
|
||||
return next.transport
|
||||
},
|
||||
})
|
||||
clients.push(client)
|
||||
|
||||
const connectPromise = client.connect()
|
||||
first.triggerOpen()
|
||||
await connectPromise
|
||||
expect(client.getConnectionState().status).toBe('connected')
|
||||
|
||||
first.triggerClose({ code: 1008, reason: 'Replaced by new connection' })
|
||||
expect(client.getConnectionState().status).toBe('disconnected')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(client.getConnectionState().status).toBe('connecting')
|
||||
|
||||
second.triggerOpen()
|
||||
expect(client.getConnectionState().status).toBe('connected')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
test('requires explicit relay session identity when URL clientId is missing', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://relay.test/ws?role=client&serverId=srv_test&v=2',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Relay client requires clientSessionKey or URL clientId')
|
||||
})
|
||||
|
||||
test('rejects relay identity mismatch between URL clientId and clientSessionKey', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://relay.test/ws?role=client&serverId=srv_test&clientId=clsk_a&v=2',
|
||||
clientSessionKey: 'clsk_b',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Relay clientId and clientSessionKey must match when both are provided')
|
||||
})
|
||||
|
||||
test('requires explicit direct session identity when URL clientSessionKey is missing', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://127.0.0.1:6767/ws',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Direct client requires clientSessionKey or URL clientSessionKey')
|
||||
})
|
||||
|
||||
test('rejects direct identity mismatch between URL and config clientSessionKey', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://127.0.0.1:6767/ws?clientSessionKey=clsk_a',
|
||||
clientSessionKey: 'clsk_b',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Direct URL clientSessionKey and config clientSessionKey must match when both are provided')
|
||||
})
|
||||
|
||||
test('logs configured runtime generation in connection transition events', async () => {
|
||||
const logger = createMockLogger()
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
runtimeGeneration: 7,
|
||||
transportFactory: () => mock.transport,
|
||||
})
|
||||
clients.push(client)
|
||||
|
||||
const connectPromise = client.connect()
|
||||
mock.triggerOpen()
|
||||
await connectPromise
|
||||
|
||||
const transitionPayloads = logger.info.mock.calls
|
||||
.filter(([, message]) => message === 'DaemonClientTransition')
|
||||
.map(([payload]) => payload as { generation?: number | null })
|
||||
expect(transitionPayloads.length).toBeGreaterThan(0)
|
||||
for (const payload of transitionPayloads) {
|
||||
expect(payload.generation).toBe(7)
|
||||
}
|
||||
})
|
||||
|
||||
test('subscribes to checkout diff updates via RPC handshake', async () => {
|
||||
const logger = createMockLogger()
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -234,7 +399,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -302,7 +467,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -361,7 +526,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -432,7 +597,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -494,7 +659,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -540,7 +705,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -621,7 +786,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -664,7 +829,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -716,7 +881,7 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory,
|
||||
@@ -743,7 +908,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -797,7 +962,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -872,7 +1037,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -928,7 +1093,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -974,7 +1139,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1016,7 +1181,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1037,7 +1202,12 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(mock.sent).toHaveLength(0)
|
||||
expect(mock.sent).toHaveLength(1)
|
||||
const bufferedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
|
||||
expect(bufferedAck?.messageType).toBe(TerminalBinaryMessageType.Ack)
|
||||
expect(bufferedAck?.streamId).toBe(19)
|
||||
expect(bufferedAck?.offset).toBe(8)
|
||||
mock.sent.length = 0
|
||||
|
||||
const seen: string[] = []
|
||||
client.onTerminalStreamData(19, (chunk) => {
|
||||
@@ -1045,11 +1215,7 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
|
||||
expect(seen.join('')).toContain('buffered')
|
||||
expect(mock.sent).toHaveLength(1)
|
||||
const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
|
||||
expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack)
|
||||
expect(ackFrame?.streamId).toBe(19)
|
||||
expect(ackFrame?.offset).toBe(8)
|
||||
expect(mock.sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('stops delivering and acking after terminal_stream_exit', async () => {
|
||||
@@ -1057,7 +1223,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1106,7 +1272,11 @@ describe('DaemonClient', () => {
|
||||
)
|
||||
|
||||
expect(seen).toEqual(['before-exit'])
|
||||
expect(mock.sent).toHaveLength(0)
|
||||
expect(mock.sent).toHaveLength(1)
|
||||
const postExitAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
|
||||
expect(postExitAck?.messageType).toBe(TerminalBinaryMessageType.Ack)
|
||||
expect(postExitAck?.streamId).toBe(23)
|
||||
expect(postExitAck?.offset).toBe(22)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
@@ -1115,7 +1285,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1165,7 +1335,11 @@ describe('DaemonClient', () => {
|
||||
)
|
||||
|
||||
expect(seen).toEqual([])
|
||||
expect(mock.sent).toHaveLength(0)
|
||||
expect(mock.sent).toHaveLength(1)
|
||||
const detachedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
|
||||
expect(detachedAck?.messageType).toBe(TerminalBinaryMessageType.Ack)
|
||||
expect(detachedAck?.streamId).toBe(31)
|
||||
expect(detachedAck?.offset).toBe(12)
|
||||
})
|
||||
|
||||
test('parses canonical agent_stream tool_call payloads without crashing', async () => {
|
||||
@@ -1173,7 +1347,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1244,7 +1418,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1296,7 +1470,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1388,7 +1562,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1458,7 +1632,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1494,7 +1668,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
|
||||
@@ -77,7 +77,6 @@ import {
|
||||
describeTransportClose,
|
||||
describeTransportError,
|
||||
encodeUtf8String,
|
||||
safeRandomId,
|
||||
type DaemonTransport,
|
||||
type DaemonTransportFactory,
|
||||
type WebSocketFactory,
|
||||
@@ -110,6 +109,7 @@ export type ConnectionState =
|
||||
| { status: 'connecting'; attempt: number }
|
||||
| { status: 'connected' }
|
||||
| { status: 'disconnected'; reason?: string }
|
||||
| { status: 'disposed' }
|
||||
|
||||
export type DaemonEvent =
|
||||
| {
|
||||
@@ -144,11 +144,14 @@ export type DaemonEventHandler = (event: DaemonEvent) => void
|
||||
|
||||
export type DaemonClientConfig = {
|
||||
url: string
|
||||
clientSessionKey?: string
|
||||
runtimeGeneration?: number | null
|
||||
authHeader?: string
|
||||
suppressSendErrors?: boolean
|
||||
transportFactory?: DaemonTransportFactory
|
||||
webSocketFactory?: WebSocketFactory
|
||||
logger?: Logger
|
||||
connectTimeoutMs?: number
|
||||
e2ee?: {
|
||||
enabled?: boolean
|
||||
daemonPublicKeyB64?: string
|
||||
@@ -297,6 +300,7 @@ class DaemonRpcError extends Error {
|
||||
|
||||
const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500
|
||||
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 15000
|
||||
|
||||
/** Default timeout for waiting for connection before sending queued messages */
|
||||
const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000
|
||||
@@ -308,6 +312,45 @@ function isWaiterTimeoutError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.startsWith('Timeout waiting for message')
|
||||
}
|
||||
|
||||
function normalizeClientSessionKey(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function hashForLog(value: string): string {
|
||||
let hash = 0
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) | 0
|
||||
}
|
||||
return `h_${Math.abs(hash).toString(16)}`
|
||||
}
|
||||
|
||||
function toReasonCode(reason: string | null | undefined): string | null {
|
||||
if (!reason) {
|
||||
return null
|
||||
}
|
||||
const normalized = reason.toLowerCase()
|
||||
if (normalized.includes('timed out')) {
|
||||
return 'connect_timeout'
|
||||
}
|
||||
if (normalized.includes('disposed')) {
|
||||
return 'disposed'
|
||||
}
|
||||
if (normalized.includes('client closed')) {
|
||||
return 'client_closed'
|
||||
}
|
||||
if (normalized.includes('transport')) {
|
||||
return 'transport_error'
|
||||
}
|
||||
if (normalized.includes('failed to connect')) {
|
||||
return 'connect_failed'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
interface PendingSend {
|
||||
message: SessionInboundMessage
|
||||
resolve: () => void
|
||||
@@ -328,6 +371,7 @@ export class DaemonClient {
|
||||
private checkoutStatusInFlight: Map<string, Promise<CheckoutStatusPayload>> = new Map()
|
||||
private connectionListeners: Set<(status: ConnectionState) => void> = new Set()
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private connectTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private pendingGenericTransportErrorTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectAttempt = 0
|
||||
private shouldReconnect = true
|
||||
@@ -343,11 +387,30 @@ export class DaemonClient {
|
||||
private terminalDirectorySubscriptions = new Set<string>()
|
||||
private logger: Logger
|
||||
private pendingSendQueue: PendingSend[] = []
|
||||
private relayClientId: string | null = null
|
||||
private terminalStreams: TerminalStreamManager
|
||||
private readonly logConnectionPath: 'direct' | 'relay'
|
||||
private readonly logServerId: string | null
|
||||
private readonly logClientSessionKeyHash: string
|
||||
private readonly logGeneration: number | null
|
||||
|
||||
constructor(private config: DaemonClientConfig) {
|
||||
this.logger = config.logger ?? consoleLogger
|
||||
this.logConnectionPath = isRelayClientWebSocketUrl(this.config.url) ? 'relay' : 'direct'
|
||||
let parsedUrlForLog: URL | null = null
|
||||
try {
|
||||
parsedUrlForLog = new URL(this.config.url)
|
||||
} catch {
|
||||
parsedUrlForLog = null
|
||||
}
|
||||
const parsedServerIdForLog = normalizeClientSessionKey(
|
||||
parsedUrlForLog?.searchParams.get('serverId')
|
||||
)
|
||||
this.logServerId = parsedServerIdForLog ?? parsedUrlForLog?.host ?? null
|
||||
this.logClientSessionKeyHash = 'h_unresolved'
|
||||
this.logGeneration =
|
||||
typeof this.config.runtimeGeneration === 'number' && Number.isFinite(this.config.runtimeGeneration)
|
||||
? this.config.runtimeGeneration
|
||||
: null
|
||||
this.terminalStreams = new TerminalStreamManager({
|
||||
sendAck: (ack) => {
|
||||
this.sendBinaryFrame({
|
||||
@@ -359,20 +422,47 @@ export class DaemonClient {
|
||||
})
|
||||
},
|
||||
})
|
||||
const configClientSessionKey = normalizeClientSessionKey(this.config.clientSessionKey)
|
||||
let parsed: URL | null = null
|
||||
try {
|
||||
parsed = new URL(this.config.url)
|
||||
} catch {
|
||||
// ignore - invalid URL will be handled on connect
|
||||
}
|
||||
|
||||
// Relay requires a clientId so the daemon can create an independent
|
||||
// socket + E2EE channel per connected client. Generate one per DaemonClient
|
||||
// instance (stable across reconnects in this tab/app session).
|
||||
// socket + E2EE channel per connected client.
|
||||
if (isRelayClientWebSocketUrl(this.config.url)) {
|
||||
try {
|
||||
const parsed = new URL(this.config.url)
|
||||
if (!parsed.searchParams.get('clientId')) {
|
||||
this.relayClientId = `clt_${safeRandomId()}`
|
||||
parsed.searchParams.set('clientId', this.relayClientId)
|
||||
this.config.url = parsed.toString()
|
||||
}
|
||||
} catch {
|
||||
// ignore - invalid URL will be handled on connect
|
||||
const urlClientId = normalizeClientSessionKey(parsed?.searchParams.get('clientId'))
|
||||
if (urlClientId && configClientSessionKey && configClientSessionKey !== urlClientId) {
|
||||
throw new Error('Relay clientId and clientSessionKey must match when both are provided')
|
||||
}
|
||||
const resolvedClientSessionKey = configClientSessionKey ?? urlClientId
|
||||
if (!resolvedClientSessionKey) {
|
||||
throw new Error('Relay client requires clientSessionKey or URL clientId')
|
||||
}
|
||||
this.config.clientSessionKey = resolvedClientSessionKey
|
||||
this.logClientSessionKeyHash = hashForLog(resolvedClientSessionKey)
|
||||
if (parsed && !urlClientId) {
|
||||
parsed.searchParams.set('clientId', resolvedClientSessionKey)
|
||||
this.config.url = parsed.toString()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const urlClientSessionKey = normalizeClientSessionKey(parsed?.searchParams.get('clientSessionKey'))
|
||||
if (urlClientSessionKey && configClientSessionKey && configClientSessionKey !== urlClientSessionKey) {
|
||||
throw new Error('Direct URL clientSessionKey and config clientSessionKey must match when both are provided')
|
||||
}
|
||||
const resolvedClientSessionKey = configClientSessionKey ?? urlClientSessionKey
|
||||
if (!resolvedClientSessionKey) {
|
||||
throw new Error('Direct client requires clientSessionKey or URL clientSessionKey')
|
||||
}
|
||||
this.config.clientSessionKey = resolvedClientSessionKey
|
||||
this.logClientSessionKeyHash = hashForLog(resolvedClientSessionKey)
|
||||
if (parsed && !urlClientSessionKey) {
|
||||
parsed.searchParams.set('clientSessionKey', resolvedClientSessionKey)
|
||||
this.config.url = parsed.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +471,9 @@ export class DaemonClient {
|
||||
// ============================================================================
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.connectionState.status === 'disposed') {
|
||||
throw new Error('Daemon client is disposed')
|
||||
}
|
||||
if (this.connectionState.status === 'connected') {
|
||||
return
|
||||
}
|
||||
@@ -399,6 +492,10 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private attemptConnect(): void {
|
||||
if (this.connectionState.status === 'disposed') {
|
||||
this.rejectConnect(new Error('Daemon client is disposed'))
|
||||
return
|
||||
}
|
||||
if (!this.shouldReconnect) {
|
||||
this.rejectConnect(new Error('Daemon client is closed'))
|
||||
return
|
||||
@@ -414,10 +511,8 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
try {
|
||||
// If we reconnect while the previous socket is still open (common in browsers
|
||||
// where `onerror` may fire before `onclose`), we can end up with multiple
|
||||
// concurrent relay sockets. Cloudflare then closes the old one with
|
||||
// "Replaced by new connection", causing a disconnect loop.
|
||||
// Reconnect can overlap with browser close/error delivery ordering.
|
||||
// Always dispose previous transport before constructing the next one.
|
||||
this.disposeTransport()
|
||||
const baseTransportFactory =
|
||||
this.config.transportFactory ??
|
||||
@@ -443,62 +538,55 @@ export class DaemonClient {
|
||||
this.updateConnectionState({
|
||||
status: 'connecting',
|
||||
attempt: this.reconnectAttempt,
|
||||
})
|
||||
}, { event: 'CONNECT_REQUEST' })
|
||||
this.resetConnectTimeout()
|
||||
const timeoutMs = Math.max(1, this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
|
||||
this.connectTimeout = setTimeout(() => {
|
||||
if (this.connectionState.status !== 'connecting') {
|
||||
return
|
||||
}
|
||||
this.lastErrorValue = 'Connection timed out'
|
||||
this.disposeTransport(1001, 'Connection timed out')
|
||||
this.scheduleReconnect({
|
||||
reason: 'Connection timed out',
|
||||
event: 'CONNECT_TIMEOUT',
|
||||
reasonCode: 'connect_timeout',
|
||||
})
|
||||
}, timeoutMs)
|
||||
|
||||
this.transportCleanup = [
|
||||
transport.onOpen(() => {
|
||||
this.resetConnectTimeout()
|
||||
if (this.pendingGenericTransportErrorTimeout) {
|
||||
clearTimeout(this.pendingGenericTransportErrorTimeout)
|
||||
this.pendingGenericTransportErrorTimeout = null
|
||||
}
|
||||
this.lastErrorValue = null
|
||||
this.reconnectAttempt = 0
|
||||
this.updateConnectionState({ status: 'connected' })
|
||||
this.updateConnectionState({ status: 'connected' }, { event: 'TRANSPORT_OPEN' })
|
||||
this.resubscribeCheckoutDiffSubscriptions()
|
||||
this.resubscribeTerminalDirectorySubscriptions()
|
||||
this.flushPendingSendQueue()
|
||||
this.resolveConnect()
|
||||
}),
|
||||
transport.onClose((event) => {
|
||||
this.resetConnectTimeout()
|
||||
if (this.pendingGenericTransportErrorTimeout) {
|
||||
clearTimeout(this.pendingGenericTransportErrorTimeout)
|
||||
this.pendingGenericTransportErrorTimeout = null
|
||||
}
|
||||
const closeRecord = event as { code?: unknown; reason?: unknown } | null
|
||||
const closeCode =
|
||||
closeRecord && typeof closeRecord === 'object' && typeof closeRecord.code === 'number'
|
||||
? closeRecord.code
|
||||
: null
|
||||
const closeReason =
|
||||
closeRecord && typeof closeRecord === 'object' && typeof closeRecord.reason === 'string'
|
||||
? closeRecord.reason
|
||||
: null
|
||||
const reason = describeTransportClose(event)
|
||||
if (reason) {
|
||||
this.lastErrorValue = reason
|
||||
}
|
||||
this.updateConnectionState({
|
||||
status: 'disconnected',
|
||||
...(reason ? { reason } : {}),
|
||||
this.scheduleReconnect({
|
||||
reason,
|
||||
event: 'TRANSPORT_CLOSE',
|
||||
reasonCode: 'transport_closed',
|
||||
})
|
||||
|
||||
// When connecting over the relay, only one client connection is allowed at a time.
|
||||
// If another device/tab takes over, we should not auto-reconnect and "fight" the new
|
||||
// connection (which causes flapping where both sides repeatedly replace each other).
|
||||
if (
|
||||
isRelayClientWebSocketUrl(this.config.url) &&
|
||||
closeCode === 1008 &&
|
||||
(closeReason ?? reason) === 'Replaced by new connection'
|
||||
) {
|
||||
this.shouldReconnect = false
|
||||
this.clearWaiters(new Error(reason ?? 'Replaced by new connection'))
|
||||
this.rejectPendingSendQueue(new Error(reason ?? 'Replaced by new connection'))
|
||||
this.rejectConnect(new Error(reason ?? 'Replaced by new connection'))
|
||||
return
|
||||
}
|
||||
this.scheduleReconnect(reason)
|
||||
}),
|
||||
transport.onError((event) => {
|
||||
this.resetConnectTimeout()
|
||||
const reason = describeTransportError(event)
|
||||
const isGeneric = reason === 'Transport error'
|
||||
// Browser WebSocket.onerror often provides no useful details and is followed
|
||||
@@ -514,8 +602,11 @@ export class DaemonClient {
|
||||
this.connectionState.status === 'connecting'
|
||||
) {
|
||||
this.lastErrorValue = reason
|
||||
this.updateConnectionState({ status: 'disconnected', reason })
|
||||
this.scheduleReconnect(reason)
|
||||
this.scheduleReconnect({
|
||||
reason,
|
||||
event: 'TRANSPORT_ERROR',
|
||||
reasonCode: 'transport_error',
|
||||
})
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
@@ -527,15 +618,23 @@ export class DaemonClient {
|
||||
this.pendingGenericTransportErrorTimeout = null
|
||||
}
|
||||
this.lastErrorValue = reason
|
||||
this.updateConnectionState({ status: 'disconnected', reason })
|
||||
this.scheduleReconnect(reason)
|
||||
this.scheduleReconnect({
|
||||
reason,
|
||||
event: 'TRANSPORT_ERROR',
|
||||
reasonCode: 'transport_error',
|
||||
})
|
||||
}),
|
||||
transport.onMessage((data) => this.handleTransportMessage(data)),
|
||||
]
|
||||
} catch (error) {
|
||||
this.resetConnectTimeout()
|
||||
const message = error instanceof Error ? error.message : 'Failed to connect'
|
||||
this.lastErrorValue = message
|
||||
this.scheduleReconnect(message)
|
||||
this.scheduleReconnect({
|
||||
reason: message,
|
||||
event: 'CONNECT_FAILED',
|
||||
reasonCode: 'connect_failed',
|
||||
})
|
||||
this.rejectConnect(error instanceof Error ? error : new Error(message))
|
||||
}
|
||||
}
|
||||
@@ -559,6 +658,9 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.connectionState.status === 'disposed') {
|
||||
return
|
||||
}
|
||||
this.shouldReconnect = false
|
||||
this.connectPromise = null
|
||||
this.connectResolve = null
|
||||
@@ -567,16 +669,21 @@ export class DaemonClient {
|
||||
clearTimeout(this.reconnectTimeout)
|
||||
this.reconnectTimeout = null
|
||||
}
|
||||
this.resetConnectTimeout()
|
||||
this.disposeTransport(1000, 'Client closed')
|
||||
this.clearWaiters(new Error('Daemon client closed'))
|
||||
this.rejectPendingSendQueue(new Error('Daemon client closed'))
|
||||
this.terminalStreams.clearAll()
|
||||
this.updateConnectionState({
|
||||
status: 'disconnected',
|
||||
reason: 'client_closed',
|
||||
})
|
||||
this.updateConnectionState(
|
||||
{ status: 'disposed' },
|
||||
{ event: 'DISPOSE', reason: 'Client closed', reasonCode: 'disposed' }
|
||||
)
|
||||
}
|
||||
|
||||
ensureConnected(): void {
|
||||
if (this.connectionState.status === 'disposed') {
|
||||
return
|
||||
}
|
||||
if (!this.shouldReconnect) {
|
||||
this.shouldReconnect = true
|
||||
}
|
||||
@@ -2505,6 +2612,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private cleanupTransport(): void {
|
||||
this.resetConnectTimeout()
|
||||
if (this.pendingGenericTransportErrorTimeout) {
|
||||
clearTimeout(this.pendingGenericTransportErrorTimeout)
|
||||
this.pendingGenericTransportErrorTimeout = null
|
||||
@@ -2519,6 +2627,14 @@ export class DaemonClient {
|
||||
this.transportCleanup = []
|
||||
}
|
||||
|
||||
private resetConnectTimeout(): void {
|
||||
if (!this.connectTimeout) {
|
||||
return
|
||||
}
|
||||
clearTimeout(this.connectTimeout)
|
||||
this.connectTimeout = null
|
||||
}
|
||||
|
||||
private handleTransportMessage(data: unknown): void {
|
||||
const rawData =
|
||||
data && typeof data === 'object' && 'data' in data ? (data as { data: unknown }).data : data
|
||||
@@ -2573,8 +2689,32 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private updateConnectionState(next: ConnectionState): void {
|
||||
private updateConnectionState(
|
||||
next: ConnectionState,
|
||||
metadata?: { event: string; reason?: string; reasonCode?: string }
|
||||
): void {
|
||||
const previous = this.connectionState
|
||||
this.connectionState = next
|
||||
const reasonFromNext =
|
||||
next.status === 'disconnected' && typeof next.reason === 'string'
|
||||
? next.reason
|
||||
: null
|
||||
const reason = metadata?.reason ?? reasonFromNext
|
||||
const reasonCode = metadata?.reasonCode ?? toReasonCode(reason)
|
||||
this.logger.info(
|
||||
{
|
||||
serverId: this.logServerId,
|
||||
clientSessionKeyHash: this.logClientSessionKeyHash,
|
||||
from: previous.status,
|
||||
to: next.status,
|
||||
event: metadata?.event ?? 'STATE_UPDATE',
|
||||
connectionPath: this.logConnectionPath,
|
||||
generation: this.logGeneration,
|
||||
reasonCode,
|
||||
reason,
|
||||
},
|
||||
'DaemonClientTransition'
|
||||
)
|
||||
for (const listener of this.connectionListeners) {
|
||||
try {
|
||||
listener(next)
|
||||
@@ -2584,22 +2724,17 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(reason?: string): void {
|
||||
private scheduleReconnect(input?: {
|
||||
reason?: string
|
||||
event?: string
|
||||
reasonCode?: string
|
||||
}): void {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout)
|
||||
this.reconnectTimeout = null
|
||||
}
|
||||
|
||||
if (!this.shouldReconnect || this.config.reconnect?.enabled === false) {
|
||||
this.rejectConnect(new Error(reason ?? 'Transport disconnected before connect'))
|
||||
return
|
||||
}
|
||||
|
||||
const attempt = this.reconnectAttempt
|
||||
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS
|
||||
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS
|
||||
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay)
|
||||
this.reconnectAttempt = attempt + 1
|
||||
const wasDisposed = this.connectionState.status === 'disposed'
|
||||
const reason = input?.reason
|
||||
|
||||
if (typeof reason === 'string' && reason.trim().length > 0) {
|
||||
this.lastErrorValue = reason.trim()
|
||||
@@ -2611,10 +2746,28 @@ export class DaemonClient {
|
||||
this.rejectPendingSendQueue(new Error(reason ?? 'Connection lost'))
|
||||
this.terminalStreams.clearAll()
|
||||
|
||||
if (wasDisposed) {
|
||||
this.rejectConnect(new Error(reason ?? 'Daemon client is disposed'))
|
||||
return
|
||||
}
|
||||
this.updateConnectionState({
|
||||
status: 'disconnected',
|
||||
...(reason ? { reason } : {}),
|
||||
}, {
|
||||
event: input?.event ?? 'TRANSPORT_CLOSE',
|
||||
...(reason ? { reason } : {}),
|
||||
...(input?.reasonCode ? { reasonCode: input.reasonCode } : {}),
|
||||
})
|
||||
if (!this.shouldReconnect || this.config.reconnect?.enabled === false) {
|
||||
this.rejectConnect(new Error(reason ?? 'Transport disconnected before connect'))
|
||||
return
|
||||
}
|
||||
|
||||
const attempt = this.reconnectAttempt
|
||||
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS
|
||||
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS
|
||||
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay)
|
||||
this.reconnectAttempt = attempt + 1
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.reconnectTimeout = null
|
||||
if (!this.shouldReconnect) {
|
||||
|
||||
Reference in New Issue
Block a user