diff --git a/package.json b/package.json index 8f81b9f..a442eb6 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,12 @@ "import": "./dist/compat/index.js" } }, - "files": ["dist", "README.md", "README.zh.md", "LICENSE"], + "files": [ + "dist", + "README.md", + "README.zh.md", + "LICENSE" + ], "bin": { "capnp": "./dist/cli.js" }, @@ -35,9 +40,17 @@ "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs" }, - "keywords": ["capnp", "capnproto", "serialization", "typescript"], + "keywords": [ + "capnp", + "capnproto", + "serialization", + "typescript" + ], "author": "Naeemo ", - "contributors": ["Naeemo ", "Kimi"], + "contributors": [ + "Naeemo ", + "Kimi" + ], "license": "MIT", "repository": { "type": "git", @@ -51,7 +64,7 @@ "@biomejs/biome": "^1.9.4", "@types/node": "^22.19.13", "@types/ws": "^8.5.14", - "@vitest/coverage-v8": "3.2.4", + "@vitest/coverage-v8": "^3.2.7", "glob": "^10.5.0", "lz4js": "^0.2.0", "protobufjs": "^7.4.0", @@ -60,7 +73,7 @@ "typescript": "^5.9.3", "vite": "^7.3.1", "vitepress": "^1.6.4", - "vitest": "^3.2.4", + "vitest": "^3.2.7", "ws": "^8.18.0" }, "engines": { diff --git a/src/rpc/index.ts b/src/rpc/index.ts index 55b124d..221bc7d 100644 --- a/src/rpc/index.ts +++ b/src/rpc/index.ts @@ -85,6 +85,15 @@ export { WebSocketTransport } from './websocket-transport.js'; export { TcpTransport } from './tcp-transport.js'; export { EzRpcTransport } from './ezrpc-transport.js'; +// Reconnecting Transport (production-grade resilience) +export { + ReconnectingTransport, + createReconnectingTransport, + type ReconnectingTransportOptions, + type ReconnectingTransportStateEvent, + type TransportFactory, +} from './reconnecting-transport.js'; + // Four Tables export { QuestionTable, diff --git a/src/rpc/reconnecting-transport.test.ts b/src/rpc/reconnecting-transport.test.ts new file mode 100644 index 0000000..459e383 --- /dev/null +++ b/src/rpc/reconnecting-transport.test.ts @@ -0,0 +1,544 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ReconnectingTransport, createReconnectingTransport } from './reconnecting-transport.js'; +import type { RpcMessage } from './rpc-types.js'; +import type { CompressionState, RpcTransport } from './transport.js'; + +/** 创建一个符合类型要求的最小 RpcMessage */ +function createUnimplementedMsg(): RpcMessage { + return { + type: 'unimplemented', + message: { + type: 'finish', + finish: { + questionId: 0, + releaseResultCaps: false, + requireEarlyCancellationWorkaround: false, + }, + }, + }; +} + +/** 创建一个可控的 mock transport */ +function createMockTransport(initialConnected = true): RpcTransport & { + simulateClose: (reason?: Error) => void; + simulateReceive: (msg: RpcMessage) => void; + simulateError: (error: Error) => void; +} { + const receiveQueue: Array<{ + resolve: (msg: RpcMessage | null) => void; + reject: (err: Error) => void; + }> = []; + const pendingMessages: RpcMessage[] = []; + let connected = initialConnected; + let onCloseHandler: ((reason?: Error) => void) | undefined; + let onErrorHandler: ((error: Error) => void) | undefined; + + const transport: RpcTransport & { + simulateClose: (reason?: Error) => void; + simulateReceive: (msg: RpcMessage) => void; + simulateError: (error: Error) => void; + } = { + get connected() { + return connected; + }, + + async send(_message: RpcMessage): Promise { + if (!connected) { + throw new Error('Mock transport not connected'); + } + }, + + async receive(): Promise { + if (!connected) return null; + if (pendingMessages.length > 0) { + return pendingMessages.shift()!; + } + return new Promise((resolve, reject) => { + receiveQueue.push({ resolve, reject }); + }); + }, + + close(reason?: Error): void { + connected = false; + while (receiveQueue.length > 0) { + const { resolve } = receiveQueue.shift()!; + resolve(null); + } + onCloseHandler?.(reason); + }, + + getCompressionState(): CompressionState { + return { + enabled: false, + algorithm: 'none', + bytesSent: 0, + bytesReceived: 0, + uncompressedBytesSent: 0, + uncompressedBytesReceived: 0, + messagesCompressed: 0, + messagesDecompressed: 0, + }; + }, + + set onClose(handler: ((reason?: Error) => void) | undefined) { + onCloseHandler = handler; + }, + get onClose() { + return onCloseHandler; + }, + + set onError(handler: ((error: Error) => void) | undefined) { + onErrorHandler = handler; + }, + get onError() { + return onErrorHandler; + }, + + simulateClose(reason?: Error): void { + connected = false; + while (receiveQueue.length > 0) { + const { resolve } = receiveQueue.shift()!; + resolve(null); + } + onCloseHandler?.(reason); + }, + + simulateReceive(msg: RpcMessage): void { + if (receiveQueue.length > 0) { + const { resolve } = receiveQueue.shift()!; + resolve(msg); + } else { + pendingMessages.push(msg); + } + }, + + simulateError(error: Error): void { + onErrorHandler?.(error); + }, + }; + + return transport; +} + +describe('ReconnectingTransport', () => { + it('should connect on first send', async () => { + const mockTransport = createMockTransport(); + let factoryCallCount = 0; + + const transport = new ReconnectingTransport(async () => { + factoryCallCount++; + return mockTransport; + }); + + const msg: RpcMessage = createUnimplementedMsg(); + await transport.send(msg); + + expect(factoryCallCount).toBe(1); + expect(transport.connected).toBe(true); + expect(transport.getConnectionState()).toBe('connected'); + }); + + it('should auto-reconnect on disconnect', async () => { + const transports: ReturnType[] = []; + let factoryCallCount = 0; + + const transport = new ReconnectingTransport(async () => { + factoryCallCount++; + const t = createMockTransport(); + transports.push(t); + return t; + }); + + // 初始连接 + await transport.connect(); + expect(factoryCallCount).toBe(1); + expect(transports.length).toBe(1); + + // 模拟断开 + const stateEvents: string[] = []; + transport.onStateChange = (e) => stateEvents.push(e.state); + + transports[0].simulateClose(new Error('network error')); + + // 等待重连 + await vi.waitFor(() => expect(factoryCallCount).toBe(2), { timeout: 5000 }); + + expect(transport.connected).toBe(true); + expect(stateEvents).toContain('disconnected'); + expect(stateEvents).toContain('reconnecting'); + expect(stateEvents).toContain('connected'); + }); + + it('should queue sends during reconnection', async () => { + const transports: ReturnType[] = []; + let factoryCallCount = 0; + + const transport = new ReconnectingTransport(async () => { + factoryCallCount++; + const t = createMockTransport(); + transports.push(t); + return t; + }); + + await transport.connect(); + expect(factoryCallCount).toBe(1); + + // 断开 + transports[0].simulateClose(); + + // 在断开时发送,应该排队 + const msg: RpcMessage = createUnimplementedMsg(); + const sendPromise = transport.send(msg); + + expect(transport.getSendQueueLength()).toBe(1); + + // 等待重连完成,send 应该成功 + await sendPromise; + expect(transport.connected).toBe(true); + expect(transport.getSendQueueLength()).toBe(0); + }); + + it('should hang receive during reconnection and resume', async () => { + const transports: ReturnType[] = []; + let factoryCallCount = 0; + + const transport = new ReconnectingTransport(async () => { + factoryCallCount++; + const t = createMockTransport(); + transports.push(t); + return t; + }); + + await transport.connect(); + + // 启动 receive(会挂起) + const receivePromise = transport.receive(); + + // 模拟断开 + transports[0].simulateClose(); + + // 等新连接建立 + await vi.waitFor(() => expect(factoryCallCount).toBe(2), { timeout: 5000 }); + + // 新连接发送消息 + const msg: RpcMessage = createUnimplementedMsg(); + transports[1].simulateReceive(msg); + + const received = await receivePromise; + expect(received).toEqual(msg); + }); + + it('should use exponential backoff', async () => { + const factoryCallCount = { value: 0 }; + + vi.useFakeTimers({ shouldAdvanceTime: true }); + + const transport = new ReconnectingTransport( + async () => { + factoryCallCount.value++; + throw new Error('connection refused'); + }, + { + baseDelayMs: 100, + maxDelayMs: 1000, + backoffMultiplier: 2, + } + ); + + try { + await transport.connect(); + } catch { + // 初始连接失败,触发重连 + } + + // 初始调用 1 次 + expect(factoryCallCount.value).toBe(1); + + // 推进 100ms → 第1次重连 (baseDelayMs: 100) + await vi.advanceTimersByTimeAsync(100); + expect(factoryCallCount.value).toBe(2); + + // 推进 200ms → 第2次重连 (100 * 2^1 = 200) + await vi.advanceTimersByTimeAsync(200); + expect(factoryCallCount.value).toBe(3); + + // 推进 400ms → 第3次重连 (100 * 2^2 = 400) + await vi.advanceTimersByTimeAsync(400); + expect(factoryCallCount.value).toBe(4); + + vi.useRealTimers(); + transport.close(); + }); + + it('should respect maxRetries', async () => { + let factoryCallCount = 0; + + const transport = new ReconnectingTransport( + async () => { + factoryCallCount++; + throw new Error('always fail'); + }, + { + maxRetries: 2, + baseDelayMs: 10, + retryOnInitialConnect: false, + } + ); + + await expect(transport.connect()).rejects.toThrow('always fail'); + expect(factoryCallCount).toBe(1); + expect(transport.getConnectionState()).toBe('disconnected'); + }); + + it('should reject sends after close', async () => { + const mockTransport = createMockTransport(); + const transport = new ReconnectingTransport(async () => mockTransport); + await transport.connect(); + + transport.close(); + + const msg: RpcMessage = createUnimplementedMsg(); + await expect(transport.send(msg)).rejects.toThrow('Transport closed'); + }); + + it('should return null from receive after close', async () => { + const mockTransport = createMockTransport(); + const transport = new ReconnectingTransport(async () => mockTransport); + await transport.connect(); + + transport.close(); + + const msg = await transport.receive(); + expect(msg).toBeNull(); + }); + + it('should support onError callback', async () => { + const mockTransport = createMockTransport(); + const transport = new ReconnectingTransport(async () => mockTransport); + await transport.connect(); + + const errors: Error[] = []; + transport.onError = (err) => errors.push(err); + + const testError = new Error('transport error'); + mockTransport.simulateError(testError); + + expect(errors).toContainEqual(testError); + }); + + it('createReconnectingTransport should work', async () => { + const mockTransport = createMockTransport(); + const transport = createReconnectingTransport(async () => mockTransport); + await transport.connect(); + + expect(transport.connected).toBe(true); + transport.close(); + }); + + it('should proxy compression state', async () => { + const mockTransport = createMockTransport(); + mockTransport.getCompressionState = () => ({ + enabled: true, + algorithm: 'lz4', + bytesSent: 100, + bytesReceived: 200, + uncompressedBytesSent: 300, + uncompressedBytesReceived: 400, + messagesCompressed: 5, + messagesDecompressed: 3, + }); + + const transport = new ReconnectingTransport(async () => mockTransport); + await transport.connect(); + + const state = transport.getCompressionState(); + expect(state.enabled).toBe(true); + expect(state.algorithm).toBe('lz4'); + expect(state.bytesSent).toBe(100); + + transport.close(); + }); + + it('should return default compression state when disconnected', () => { + const transport = new ReconnectingTransport(async () => createMockTransport()); + const state = transport.getCompressionState(); + expect(state.enabled).toBe(false); + expect(state.algorithm).toBe('none'); + }); + + it('should timeout hanging receive', async () => { + const mockTransport = createMockTransport(); + const transport = new ReconnectingTransport(async () => mockTransport, { + callTimeoutMs: 50, + }); + await transport.connect(); + + // receive 会挂起,因为 mockTransport 没有消息 + await expect(transport.receive()).rejects.toThrow('Receive timeout after 50ms'); + transport.close(); + }); + + it('should resolve pending receive with null when closed', async () => { + const mockTransport = createMockTransport(); + const transport = new ReconnectingTransport(async () => mockTransport); + await transport.connect(); + + const receivePromise = transport.receive(); + transport.close(); + + const msg = await receivePromise; + expect(msg).toBeNull(); + }); + + it('should maintain FIFO order for multiple receives', async () => { + const mockTransport = createMockTransport(); + const transport = new ReconnectingTransport(async () => mockTransport); + await transport.connect(); + + const promise1 = transport.receive(); + const promise2 = transport.receive(); + + const msg1 = createUnimplementedMsg(); + const msg2 = createUnimplementedMsg(); + + // 模拟两个消息依次到达 + mockTransport.simulateReceive(msg1); + mockTransport.simulateReceive(msg2); + + const result1 = await promise1; + const result2 = await promise2; + expect(result1).toEqual(msg1); + expect(result2).toEqual(msg2); + + transport.close(); + }); + + it('should reject send when transport send fails during flush', async () => { + const transports: ReturnType[] = []; + const transport = new ReconnectingTransport( + async () => { + const t = createMockTransport(); + t.send = async () => { + throw new Error('send failed'); + }; + transports.push(t); + return t; + }, + { baseDelayMs: 10 } + ); + + await transport.connect(); + transports[0].simulateClose(); + + // 直接 await sendPromise,它会等待重连和 flush + await expect(transport.send(createUnimplementedMsg())).rejects.toThrow('send failed'); + }); + + it('should abort connecting when closed during connection', async () => { + const transport = new ReconnectingTransport( + async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return createMockTransport(); + }, + { connectTimeoutMs: 2000 } + ); + + const connectPromise = transport.connect(); + // 稍微等待让 doConnect 开始 + await new Promise((resolve) => setTimeout(resolve, 50)); + + transport.close(); + + await expect(connectPromise).rejects.toThrow('Transport closed'); + }); + + it('should emit state change events in correct order', async () => { + const transports: ReturnType[] = []; + let factoryCallCount = 0; + + const transport = new ReconnectingTransport(async () => { + factoryCallCount++; + const t = createMockTransport(); + transports.push(t); + return t; + }); + + await transport.connect(); + + const stateEvents: Array<{ state: string; attempt?: number }> = []; + transport.onStateChange = (e) => stateEvents.push({ state: e.state, attempt: e.attempt }); + + transports[0].simulateClose(new Error('network error')); + + await vi.waitFor(() => expect(factoryCallCount).toBe(2), { timeout: 5000 }); + + // 验证状态顺序 + const states = stateEvents.map((e) => e.state); + const disconnectedIdx = states.indexOf('disconnected'); + const reconnectingIdx = states.indexOf('reconnecting'); + const connectedIdx = states.indexOf('connected'); + + expect(disconnectedIdx).toBeGreaterThanOrEqual(0); + expect(reconnectingIdx).toBeGreaterThan(disconnectedIdx); + expect(connectedIdx).toBeGreaterThan(reconnectingIdx); + + // 验证 reconnecting 事件的 attempt + const reconnectingEvent = stateEvents.find((e) => e.state === 'reconnecting'); + expect(reconnectingEvent?.attempt).toBe(1); + + transport.close(); + }); + + it('should not double start receive loop', async () => { + const mockTransport = createMockTransport(); + const transport = new ReconnectingTransport(async () => mockTransport); + await transport.connect(); + + // 连续调用两个 receive,不应该启动两个 receive loop + const promise1 = transport.receive(); + const promise2 = transport.receive(); + + const msg1 = createUnimplementedMsg(); + const msg2 = createUnimplementedMsg(); + + mockTransport.simulateReceive(msg1); + mockTransport.simulateReceive(msg2); + + const result1 = await promise1; + const result2 = await promise2; + expect(result1).toEqual(msg1); + expect(result2).toEqual(msg2); + + transport.close(); + }); + + it('should clear receive queue on close with timeout', async () => { + const mockTransport = createMockTransport(); + const transport = new ReconnectingTransport(async () => mockTransport, { + callTimeoutMs: 5000, + }); + await transport.connect(); + + const promise = transport.receive(); + + // 在 timeout 之前 close + transport.close(); + + const result = await promise; + expect(result).toBeNull(); + }); + + it('should not double settle send on timeout and flush race', async () => { + const mockTransport = createMockTransport(); + const transport = new ReconnectingTransport(async () => mockTransport, { + callTimeoutMs: 10, + baseDelayMs: 1000, // 重连延迟长,确保 timeout 先触发 + }); + await transport.connect(); + + mockTransport.simulateClose(); + + // send 会进入队列,然后 timeout 触发,但 flush 还没发生 + await expect(transport.send(createUnimplementedMsg())).rejects.toThrow('Send timeout'); + }); +}); diff --git a/src/rpc/reconnecting-transport.ts b/src/rpc/reconnecting-transport.ts new file mode 100644 index 0000000..14bc948 --- /dev/null +++ b/src/rpc/reconnecting-transport.ts @@ -0,0 +1,459 @@ +/** + * Reconnecting Transport — 生产级自动重连 + 错误处理 + * + * 装饰器模式,包装任意 RpcTransport,提供: + * - 指数退避自动重连 + * - 断链时 send/receive 透明挂起 + * - 调用超时 + * - 连接状态事件 + * - 最大重试次数限制 + * + * 与 RpcConnection 配合使用时,底层断连对上层完全透明。 + */ + +import type { RpcMessage } from './rpc-types.js'; +import type { CompressionState, RpcTransport } from './transport.js'; + +/** 重连策略配置 */ +export interface ReconnectingTransportOptions { + /** 最大重试次数 (默认 Infinity) */ + maxRetries?: number; + /** 初始重试延迟 ms (默认 1000) */ + baseDelayMs?: number; + /** 最大重试延迟 ms (默认 30000) */ + maxDelayMs?: number; + /** 退避乘数 (默认 2) */ + backoffMultiplier?: number; + /** 调用超时 ms (默认 30000) */ + callTimeoutMs?: number; + /** 连接超时 ms (默认 10000) */ + connectTimeoutMs?: number; + /** 是否首次连接失败时立即重试 (默认 true) */ + retryOnInitialConnect?: boolean; +} + +/** 连接状态变化事件 */ +export interface ReconnectingTransportStateEvent { + state: 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'closed'; + attempt?: number; + error?: Error; + delay?: number; +} + +/** 发送队列项 */ +interface SendQueueItem { + message: RpcMessage; + resolve: () => void; + reject: (err: Error) => void; + timer: ReturnType; + /** 标记是否已被 settle(防止 timeout 和 flush 双重 resolve/reject) */ + settled: boolean; +} + +/** receive 挂起项 */ +interface ReceiveQueueItem { + resolve: (msg: RpcMessage | null) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +/** 底层 transport 工厂 */ +export type TransportFactory = () => Promise; + +/** + * ReconnectingTransport — 为任意 RpcTransport 添加自动重连能力 + * + * 使用装饰器模式,保持 RpcTransport 接口完全兼容。 + * 底层断连时:send 排队、receive 挂起,重连后自动恢复。 + */ +export class ReconnectingTransport implements RpcTransport { + private transport: RpcTransport | null = null; + private state: 'idle' | 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'closed' = + 'idle'; + private sendQueue: SendQueueItem[] = []; + private receiveQueue: ReceiveQueueItem[] = []; + private connectionAttempts = 0; + private retryTimer: ReturnType | null = null; + private connectPromise: Promise | null = null; + private abortController: AbortController | null = null; + private receiveLoopRunning = false; + + onClose?: (reason?: Error) => void; + onError?: (error: Error) => void; + + /** 连接状态变化回调 */ + onStateChange?: (event: ReconnectingTransportStateEvent) => void; + + constructor( + private factory: TransportFactory, + private options: ReconnectingTransportOptions = {} + ) {} + + // -------------------------------------------------------------------------- + // 连接管理 + // -------------------------------------------------------------------------- + + /** + * 手动发起首次连接(可选) + * + * 如果未调用 connect,第一次 send/receive 会自动触发连接。 + */ + async connect(): Promise { + if (this.state === 'connected') return; + if (this.state === 'closed') throw new Error('Transport closed'); + if (this.state === 'connecting' && this.connectPromise) return this.connectPromise; + + this.connectPromise = this.doConnect(); + return this.connectPromise; + } + + private async doConnect(): Promise { + this.state = 'connecting'; + this.emitStateChange({ state: 'connecting', attempt: this.connectionAttempts + 1 }); + + // 每次连接创建新的 AbortController + this.abortController = new AbortController(); + + try { + const transport = await this.withTimeout( + this.factory(), + this.options.connectTimeoutMs ?? 10000, + 'Connection timeout' + ); + + this.transport = transport; + this.connectionAttempts = 0; + + // 代理底层事件 + transport.onClose = (reason) => this.onTransportClose(reason); + transport.onError = (error) => this.onTransportError(error); + + this.state = 'connected'; + this.emitStateChange({ state: 'connected' }); + this.connectPromise = null; + + // 发送排队消息 + 恢复 receive 监听 + await this.flushSendQueue(); + this.resumeReceiveLoop(); + } catch (error) { + this.connectPromise = null; + const err = error instanceof Error ? error : new Error(String(error)); + + const currentState: string = this.state; + if (currentState === 'closed') { + throw new Error('Transport closed during connection'); + } + + this.state = 'disconnected'; + this.emitStateChange({ state: 'disconnected', error: err }); + + const retryOnInitial = this.options.retryOnInitialConnect ?? true; + if (retryOnInitial) { + this.scheduleReconnect(err); + } + // retryOnInitialConnect: false 时保持 disconnected,让用户可再次调用 connect() + throw err; + } + } + + // -------------------------------------------------------------------------- + // RpcTransport 接口 + // -------------------------------------------------------------------------- + + get connected(): boolean { + return this.state === 'connected'; + } + + async send(message: RpcMessage): Promise { + if (this.state === 'closed') { + throw new Error('Transport closed'); + } + + if (this.state === 'connected' && this.transport) { + return this.transport.send(message); + } + + // 未连接 → 自动触发连接 + if (this.state === 'idle') { + this.connect().catch(() => { + // 连接失败由 scheduleReconnect 处理 + }); + } + + // 排队等待 + return new Promise((resolve, reject) => { + const timeout = this.options.callTimeoutMs ?? 30000; + const item: SendQueueItem = { + message, + resolve, + reject, + timer: undefined as unknown as ReturnType, + settled: false, + }; + item.timer = setTimeout(() => { + item.settled = true; + const idx = this.sendQueue.findIndex((i) => i === item); + if (idx >= 0) this.sendQueue.splice(idx, 1); + reject(new Error(`Send timeout after ${timeout}ms`)); + }, timeout); + this.sendQueue.push(item); + }); + } + + async receive(): Promise { + if (this.state === 'closed') return null; + + // 未连接 → 自动触发连接 + if (this.state === 'idle') { + this.connect().catch(() => { + // 忽略连接错误,挂起等待 + }); + } + + // 排队等待,由统一的接收循环服务 + return new Promise((resolve, reject) => { + const timeout = this.options.callTimeoutMs ?? 30000; + const timer = setTimeout(() => { + const idx = this.receiveQueue.findIndex((i) => i.timer === timer); + if (idx >= 0) this.receiveQueue.splice(idx, 1); + reject(new Error(`Receive timeout after ${timeout}ms`)); + }, timeout); + + this.receiveQueue.push({ resolve, reject, timer }); + this.tryStartReceiveLoop(); + }); + } + + close(reason?: Error): void { + if (this.state === 'closed') return; + + this.state = 'closed'; + this.abortController?.abort(); + + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + + this.transport?.close(reason); + this.transport = null; + + // 释放所有挂起的 receive + while (this.receiveQueue.length > 0) { + const item = this.receiveQueue.shift()!; + clearTimeout(item.timer); + item.resolve(null); + } + + // 拒绝所有挂起的 send + while (this.sendQueue.length > 0) { + const item = this.sendQueue.shift()!; + clearTimeout(item.timer); + if (!item.settled) { + item.reject(new Error('Transport closed')); + } + } + + this.onClose?.(reason); + this.emitStateChange({ state: 'closed', error: reason }); + } + + getCompressionState(): CompressionState { + if (this.transport) { + return this.transport.getCompressionState(); + } + return { + enabled: false, + algorithm: 'none', + bytesSent: 0, + bytesReceived: 0, + uncompressedBytesSent: 0, + uncompressedBytesReceived: 0, + messagesCompressed: 0, + messagesDecompressed: 0, + }; + } + + // -------------------------------------------------------------------------- + // 状态查询 + // -------------------------------------------------------------------------- + + /** 当前连接状态 */ + getConnectionState(): ReconnectingTransportStateEvent['state'] | 'idle' { + return this.state; + } + + /** 已尝试重连次数 */ + getConnectionAttempts(): number { + return this.connectionAttempts; + } + + /** 发送队列长度 */ + getSendQueueLength(): number { + return this.sendQueue.length; + } + + // -------------------------------------------------------------------------- + // 内部方法 + // -------------------------------------------------------------------------- + + private onTransportClose(reason?: Error): void { + if (this.state === 'closed' || this.state === 'reconnecting') return; + + this.state = 'disconnected'; + this.transport = null; + this.emitStateChange({ state: 'disconnected', error: reason }); + this.scheduleReconnect(reason); + } + + private onTransportError(error: Error): void { + this.onError?.(error); + } + + private scheduleReconnect(error?: Error): void { + if (this.state === 'closed') return; + + const maxRetries = this.options.maxRetries ?? Number.POSITIVE_INFINITY; + if (this.connectionAttempts >= maxRetries) { + this.close(new Error(`Max retries (${maxRetries}) exceeded: ${error?.message ?? 'unknown'}`)); + return; + } + + const baseDelay = this.options.baseDelayMs ?? 1000; + const maxDelay = this.options.maxDelayMs ?? 30000; + const multiplier = this.options.backoffMultiplier ?? 2; + const delay = Math.min(baseDelay * multiplier ** this.connectionAttempts, maxDelay); + + this.state = 'reconnecting'; + this.emitStateChange({ + state: 'reconnecting', + attempt: this.connectionAttempts + 1, + delay, + error, + }); + + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.connectionAttempts++; + this.doConnect().catch(() => { + // 连接失败由 doConnect 内部调用 scheduleReconnect + }); + }, delay); + } + + private async flushSendQueue(): Promise { + if (!this.transport || this.state !== 'connected') return; + + const queue = [...this.sendQueue]; + this.sendQueue = []; + + for (const item of queue) { + clearTimeout(item.timer); + if (item.settled) continue; + try { + await this.transport.send(item.message); + item.resolve(); + } catch (error) { + item.reject(error instanceof Error ? error : new Error(String(error))); + } + } + } + + /** 尝试启动后台接收循环(如果尚未运行) */ + private tryStartReceiveLoop(): void { + this.resumeReceiveLoop(); + } + + /** 连接恢复后,启动后台接收循环来服务挂起的 receive */ + private resumeReceiveLoop(): void { + if ( + !this.transport || + this.state !== 'connected' || + this.receiveQueue.length === 0 || + this.receiveLoopRunning + ) { + return; + } + + this.receiveLoopRunning = true; + + const loop = async () => { + while (this.transport && this.state === 'connected' && this.receiveQueue.length > 0) { + try { + const msg = await this.transport.receive(); + if (msg === null) { + // 底层断开 + this.onTransportClose(); + return; + } + const waiter = this.receiveQueue.shift(); + if (waiter) { + clearTimeout(waiter.timer); + waiter.resolve(msg); + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + const waiter = this.receiveQueue.shift(); + if (waiter) { + clearTimeout(waiter.timer); + waiter.reject(err); + } + } + } + }; + + loop() + .finally(() => { + this.receiveLoopRunning = false; + }) + .catch(() => { + // 错误由 onTransportClose / receive 调用方处理 + }); + } + + private async withTimeout(promise: Promise, ms: number, message: string): Promise { + const abortController = this.abortController; + if (!abortController) { + return promise; + } + if (abortController.signal.aborted) { + return Promise.reject(new Error('Transport closed')); + } + return Promise.race([ + promise, + new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms); + const onAbort = () => { + clearTimeout(timer); + reject(new Error('Transport closed')); + }; + abortController.signal.addEventListener('abort', onAbort, { once: true }); + }), + ]); + } + + private emitStateChange(event: ReconnectingTransportStateEvent): void { + this.onStateChange?.(event); + } +} + +/** + * 便捷工厂:包装一个 ReconnectingTransport + * + * @example + * ```typescript + * const transport = createReconnectingTransport( + * () => TcpTransport.connect('localhost', 7000), + * { maxRetries: 10, baseDelayMs: 500 } + * ); + * await transport.connect(); + * const connection = new RpcConnection(transport); + * await connection.start(); + * ``` + */ +export function createReconnectingTransport( + factory: TransportFactory, + options?: ReconnectingTransportOptions +): ReconnectingTransport { + return new ReconnectingTransport(factory, options); +}