From 8ecf41ccb7b1b96acbfb84574be737b0148a6b5a Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sat, 31 Oct 2026 18:19:01 +0800 Subject: [PATCH] fix(bridge): preserve QQ runtime failures across session updates Surface connection failures immediately to waiting requests and keep the original error visible until the session reconnects. Ignore late login updates from the failed connection. --- packages/bridge/src/hook-session.ts | 63 ++++++++++++--- packages/bridge/src/qq-hook-client.ts | 85 +++++++++++++------- packages/bridge/tests/hook-session.test.ts | 78 ++++++++++++++++++ packages/bridge/tests/qq-hook-client.test.ts | 83 ++++++++++++++++++- 4 files changed, 269 insertions(+), 40 deletions(-) diff --git a/packages/bridge/src/hook-session.ts b/packages/bridge/src/hook-session.ts index 4b975607..dcf0594a 100644 --- a/packages/bridge/src/hook-session.ts +++ b/packages/bridge/src/hook-session.ts @@ -88,6 +88,7 @@ export class HookSession extends EventEmitter { private _status: HookProcessStatus = 'available'; private _error = ''; + private runtimeFailure = ''; private _uin = '0'; private _method = ''; private _name = ''; @@ -500,6 +501,11 @@ export class HookSession extends EventEmitter { * defaults to the current `loggedIn`; callers that just tore the client * down pass the value captured *before* teardown (teardown clears it). */ private applyStatus(wasLoggedIn: boolean = this.loggedIn, error = ''): void { + if (this.runtimeFailure) { + this.setStatus('error', this.runtimeFailure); + return; + } + this.setStatus( statusFor({ injected: this.injected, @@ -559,7 +565,7 @@ export class HookSession extends EventEmitter { this.pid, renderParamsVerbose(this.toInfo()), ]); - this.tearDownClient(); + this.tearDownClient(true); this.applyStatus(wasLoggedIn); try { @@ -607,6 +613,7 @@ export class HookSession extends EventEmitter { } if (!this.client) { + this.runtimeFailure = ''; this.client = this.makeClient(this.pid); this.sender = new HookPacketClient(this.client, (healthy) => this.setOutboundHealthy(healthy), @@ -623,6 +630,11 @@ export class HookSession extends EventEmitter { try { await client.connectAll({ recv: true }); + + if (this.runtimeFailure) { + throw new Error(this.runtimeFailure); + } + this.connected = true; const loginState = client.getLoginState(); @@ -650,7 +662,7 @@ export class HookSession extends EventEmitter { this._error = errorMessage(error); // Drop the client so the next attempt builds a fresh socket pair. // A failed connect was never logged in → 'connecting' (or 'available'). - this.tearDownClient(); + this.tearDownClient(true); this.applyStatus(false, this._error); this.log.trace(() => [ 'hook_session_connect_terminal pid=%d outcome=failed reason=connect_failed error=%j state=%s elapsedMs=%d', @@ -664,11 +676,28 @@ export class HookSession extends EventEmitter { } private bindClient(client: QqHookClient): void { + client.on('runtimeError', (error) => { + if (this.client !== client || this.disposed) { + return; + } + + const wasLoggedIn = this.loggedIn; + this.runtimeFailure = errorMessage(error); + this.loggedIn = false; + this.stopLoginReconcile(); + this.resetReceiveWatch(); + this.applyStatus(wasLoggedIn); + this.log.error('QQ runtime unavailable: PID=%d err=%s', this.pid, this.runtimeFailure); + + if (wasLoggedIn) { + this.emit('disconnected', true); + } + }); client.on('packet', (packet) => this.handlePacket(packet)); client.on('loginState', (state) => this.handleLoginState(state)); client.on('error', (error) => { const msg = errorMessage(error); - this._error = msg; + this._error = this.runtimeFailure || msg; this.log.warn('pipe error: PID=%d err=%s', this.pid, msg); }); client.on('close', () => { @@ -698,7 +727,7 @@ export class HookSession extends EventEmitter { }); } - private tearDownClient(): void { + private tearDownClient(preserveRuntimeFailure = false): void { this.stopLoginReconcile(); this.resetReceiveWatch(); const client = this.client; @@ -714,6 +743,11 @@ export class HookSession extends EventEmitter { } this.client = null; + + if (!preserveRuntimeFailure) { + this.runtimeFailure = ''; + } + this.sender = null; this.bound = false; this.connected = false; @@ -723,6 +757,10 @@ export class HookSession extends EventEmitter { } private handleLoginState(state: QqHookLoginState): void { + if (this.runtimeFailure) { + return; + } + const wasLoggedIn = this.loggedIn; const previousUin = this._uin; this._uin = state.uin || state.uinNumber.toString(); @@ -800,7 +838,7 @@ export class HookSession extends EventEmitter { // reconciliation, but never synthesize online state from the detection. private startLoginReconcile(): void { - if (this.loginReconcileTimer || this.disposed) { + if (this.loginReconcileTimer || this.disposed || this.runtimeFailure) { return; } @@ -829,7 +867,7 @@ export class HookSession extends EventEmitter { const client = this.client; - if (this.disposed || !client || !this.connected || this.loggedIn) { + if (this.disposed || !client || !this.connected || this.loggedIn || this.runtimeFailure) { this.stopLoginReconcile(); return; } @@ -861,7 +899,13 @@ export class HookSession extends EventEmitter { } // A reconnect owns a new client; an older read cannot supply its identity. - if (this.client !== client || this.disposed || !this.connected || this.loggedIn) { + if ( + this.client !== client || + this.disposed || + !this.connected || + this.loggedIn || + this.runtimeFailure + ) { return; } @@ -902,7 +946,8 @@ export class HookSession extends EventEmitter { this.client !== client || this.disposed || !this.connected || - this.loggedIn + this.loggedIn || + this.runtimeFailure ) { return; } @@ -911,7 +956,7 @@ export class HookSession extends EventEmitter { this.nextLoginIdentityObservationAt = Date.now() + LOGIN_IDENTITY_OBSERVE_INTERVAL_MS; } catch (error) { - if (this.client !== client || this.disposed) { + if (this.client !== client || this.disposed || this.runtimeFailure) { return; } diff --git a/packages/bridge/src/qq-hook-client.ts b/packages/bridge/src/qq-hook-client.ts index cf34f58f..0cbd17bf 100644 --- a/packages/bridge/src/qq-hook-client.ts +++ b/packages/bridge/src/qq-hook-client.ts @@ -563,6 +563,7 @@ export class QqHookClient extends EventEmitter { private recvHelloResolver: Deferred | null = null; private controlWriteChain: Promise = Promise.resolve(); private closed = false; + private runtimeFailure: Error | null = null; private loginState: QqHookLoginState = { loggedIn: false, uin: '0', uinNumber: 0n }; private readonly loginWaiters = new Set>(); @@ -615,10 +616,7 @@ export class QqHookClient extends EventEmitter { async waitForLogin({ timeoutMs = 0 } = {}): Promise { await this.connect(); - - if (this.closed) { - throw new Error('qq_hook client is closed'); - } + this.assertUsable(); if (this.loginState.loggedIn) { return this.getLoginState(); @@ -692,9 +690,7 @@ export class QqHookClient extends EventEmitter { } async connect(): Promise { - if (this.closed) { - throw new Error('qq_hook client is closed'); - } + this.assertUsable(); if (this.controlSocket && this.controlHello) { return this.controlHello; @@ -714,9 +710,7 @@ export class QqHookClient extends EventEmitter { } async startRecv(onPacket?: (packet: QqHookPacket) => void): Promise { - if (this.closed) { - throw new Error('qq_hook client is closed'); - } + this.assertUsable(); if (typeof onPacket === 'function') { this.on('packet', onPacket); @@ -996,6 +990,16 @@ export class QqHookClient extends EventEmitter { this.controlWriteChain = Promise.resolve(); } + private assertUsable(): void { + if (this.runtimeFailure) { + throw this.runtimeFailure; + } + + if (this.closed) { + throw new Error('qq_hook client is closed'); + } + } + private rejectLoginWaiters(error: Error): void { for (const waiter of this.loginWaiters) { waiter.reject(error); @@ -1033,26 +1037,28 @@ export class QqHookClient extends EventEmitter { const socket = this.controlSocket; if (!socket || socket.destroyed) { - return Promise.reject(new Error('control pipe is not connected')); + return Promise.reject( + this.runtimeFailure ?? new Error('control pipe is not connected'), + ); } - const writePromise = this.controlWriteChain.then( - () => - new Promise((resolve, reject) => { - if (!this.controlSocket || this.controlSocket.destroyed) { - reject(new Error('control pipe is not connected')); - return; - } + const writePromise = this.controlWriteChain.then(() => { + this.assertUsable(); - socket.write(payload, (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }), - ); + if (this.controlSocket !== socket || socket.destroyed) { + throw new Error('control pipe is not connected'); + } + + return new Promise((resolve, reject) => { + socket.write(payload, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }); this.controlWriteChain = writePromise.catch(() => {}); return writePromise; } @@ -1132,6 +1138,11 @@ export class QqHookClient extends EventEmitter { ), ); + if (this.closed || this.runtimeFailure) { + socket.destroy(); + this.assertUsable(); + } + if (kind === 'control') { this.controlSocket = socket; } else { @@ -1146,6 +1157,8 @@ export class QqHookClient extends EventEmitter { phase = 'hello'; const hello = await this.waitForHello(kind === 'recv'); + this.assertUsable(); + if (kind === 'control') { this.controlHello = hello; } else { @@ -1301,6 +1314,10 @@ export class QqHookClient extends EventEmitter { } private handleControlFrame(frame: PipeFrame): void { + if (this.runtimeFailure) { + return; + } + if (frame.op === PipeOp.hello) { this.resolveHello(frame, false); return; @@ -1346,6 +1363,16 @@ export class QqHookClient extends EventEmitter { frame.status, frame.requestId, ); + + if (frame.requestId === 0 && frame.cmd === 'runtime') { + this.runtimeFailure = error; + this.rejectControlPending(error); + this.rejectRecvPending(error); + this.rejectLoginWaiters(error); + this.emit('runtimeError', error); + return; + } + const ack = this.pendingAcks.get(frame.requestId); if (ack) { @@ -1363,6 +1390,10 @@ export class QqHookClient extends EventEmitter { } private handleRecvFrame(frame: PipeFrame): void { + if (this.runtimeFailure) { + return; + } + if (frame.op === PipeOp.hello) { this.resolveHello(frame, true); return; diff --git a/packages/bridge/tests/hook-session.test.ts b/packages/bridge/tests/hook-session.test.ts index 59680abb..4137fb80 100644 --- a/packages/bridge/tests/hook-session.test.ts +++ b/packages/bridge/tests/hook-session.test.ts @@ -30,6 +30,7 @@ class FakeClient extends EventEmitter { isClosed = false; isLoggedIn = false; shouldFailConnect = false; + runtimeErrorOnConnect = false; nativeReadyOnIdentityHint = false; identityHintFailures = 0; sendError: Error | null = null; @@ -38,6 +39,10 @@ class FakeClient extends EventEmitter { private loginState = { loggedIn: false, uin: '0', uinNumber: 0n }; async connectAll(_opts: { recv: boolean }): Promise { + if (this.runtimeErrorOnConnect) { + this.emit('runtimeError', new Error('QQ service unavailable')); + } + if (this.shouldFailConnect) { throw new Error('connect failed'); } @@ -102,6 +107,7 @@ function makeSession( pid?: number; pipeLive?: boolean; clientFailsConnect?: boolean; + runtimeErrorOnConnect?: boolean; onPacket?: PacketSink; detectLoginUin?: (pid: number) => Promise; nativeReadyOnIdentityHint?: boolean; @@ -123,6 +129,7 @@ function makeSession( injector, makeClient: () => { const c = new FakeClient(); + c.runtimeErrorOnConnect = opts.runtimeErrorOnConnect ?? false; if (opts.clientFailsConnect) { c.shouldFailConnect = true; @@ -152,6 +159,77 @@ function makeSession( const flush = () => new Promise((r) => setImmediate(r)); +describe('HookSession runtime failure', () => { + it('retains an initialization error instead of waiting for login', async () => { + const { session } = makeSession({ pipeLive: true, runtimeErrorOnConnect: true }); + await session.load(); + session.onPipeUp(); + await flush(); + expect(session.toInfo()).toMatchObject({ + status: 'error', + error: 'QQ service unavailable', + connected: false, + }); + session.dispose(); + }); + + it('keeps a late runtime error visible across refresh and login snapshots', async () => { + const { session, currentClient, setPipeLive } = makeSession({ pipeLive: true }); + const disconnect = vi.fn(); + session.on('disconnected', disconnect); + await session.load(); + session.onPipeUp(); + await flush(); + currentClient().fireLogin('10001'); + currentClient().emit('runtimeError', new Error('QQ service unavailable')); + currentClient().emit('error', new Error('connection closed')); + expect(session.error).toBe('QQ service unavailable'); + currentClient().fireLogin('10001'); + await session.refresh(); + expect(session.toInfo()).toMatchObject({ + status: 'error', + error: 'QQ service unavailable', + loggedIn: false, + }); + setPipeLive(false); + await session.refresh(); + expect(disconnect).toHaveBeenCalledExactlyOnceWith(true); + setPipeLive(true); + await session.refresh(); + currentClient().fireLogin('10001'); + expect(session.toInfo()).toMatchObject({ status: 'online', loggedIn: true, error: '' }); + session.dispose(); + }); + + it('retains the failure when the pipe closes and discards an in-flight login lookup', async () => { + let resolveLogin!: (uin: string) => void; + const { session, currentClient, setPipeLive } = makeSession({ + pipeLive: true, + detectLoginUin: () => + new Promise((resolve) => { + resolveLogin = resolve; + }), + }); + await session.load(); + session.onPipeUp(); + await flush(); + const client = currentClient(); + client.emit('runtimeError', new Error('QQ service unavailable')); + resolveLogin('10001'); + await flush(); + expect(client.loginIdentityHints).toEqual([]); + setPipeLive(false); + await session.refresh(); + expect(session.toInfo()).toMatchObject({ + status: 'error', + error: 'QQ service unavailable', + loggedIn: false, + connected: false, + }); + session.dispose(); + }); +}); + describe('HookSession — runtime TRACE', () => { it('records injection and existing-pipe load branches with unique terminals', async () => { const entries: LogEntry[] = []; diff --git a/packages/bridge/tests/qq-hook-client.test.ts b/packages/bridge/tests/qq-hook-client.test.ts index b142fc7c..a80b2f91 100644 --- a/packages/bridge/tests/qq-hook-client.test.ts +++ b/packages/bridge/tests/qq-hook-client.test.ts @@ -85,6 +85,7 @@ function readSendFrame(payload: Buffer): { function mockControlPipe( pid: number, onWrite: (socket: net.Socket, payload: Buffer) => void, + ...startupFrames: Buffer[] ): ReturnType { return vi.spyOn(net, 'createConnection').mockImplementation((() => { const socket = new Duplex({ @@ -101,10 +102,13 @@ function mockControlPipe( queueMicrotask(() => socket.emit( 'data', - pipeFrame({ - op: PipeOp.hello, - value0: BigInt(pid), - }), + Buffer.concat([ + pipeFrame({ + op: PipeOp.hello, + value0: BigInt(pid), + }), + ...startupFrames, + ]), ), ); }); @@ -118,6 +122,77 @@ function traceMessages(entries: LogEntry[]): string[] { .map((entry) => entry.message); } +describe('runtime initialization failures', () => { + const failureFrame = pipeFrame({ + op: PipeOp.error, + cmd: 'runtime', + status: -1, + message: 'QQ service unavailable', + }); + + it('rejects startup when a runtime failure follows hello in the same read', async () => { + const connection = mockControlPipe(778901, () => {}, failureFrame); + const client = new QqHookClient(778901); + const failure = vi.fn(); + client.on('runtimeError', failure); + + try { + await expect(client.connect()).rejects.toThrow('QQ service unavailable'); + expect(failure).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ status: -1, requestId: 0 }), + ); + } finally { + client.close(); + connection.mockRestore(); + } + }); + + it('rejects pending and future requests on a runtime failure', async () => { + const connection = mockControlPipe(778902, (socket) => { + queueMicrotask(() => socket.emit('data', failureFrame)); + }); + const client = new QqHookClient(778902); + + try { + await expect(client.sendAndWait('Test', Buffer.alloc(0))).rejects.toThrow( + 'QQ service unavailable', + ); + await expect(client.sendAndWait('Test', Buffer.alloc(0))).rejects.toThrow( + 'QQ service unavailable', + ); + await expect(client.startRecv()).rejects.toThrow('QQ service unavailable'); + await expect(client.waitForLogin()).rejects.toThrow('QQ service unavailable'); + } finally { + client.close(); + connection.mockRestore(); + } + }); + + it('rejects login and queued writes when the runtime fails as a connection resumes', async () => { + const onWrite = vi.fn(); + const sockets = mockControlPipe(778903, onWrite); + const client = new QqHookClient(778903); + + try { + await client.connect(); + const waiting = client.waitForLogin({ timeoutMs: 100 }); + const sending = client.sendNoReply('Test', Buffer.alloc(0), { ackTimeoutMs: 100 }); + const settled = Promise.allSettled([waiting, sending]); + sockets.mock.results[0]!.value.emit('data', failureFrame); + + const expected = { + status: 'rejected', + reason: expect.objectContaining({ message: 'QQ service unavailable' }), + }; + expect(await settled).toEqual([expected, expected]); + expect(onWrite).not.toHaveBeenCalled(); + } finally { + client.close(); + sockets.mockRestore(); + } + }); +}); + function runtimeTrace(entries: LogEntry[]): LogEntry[] { return entries.filter((entry) => entry.level === 'trace' && entry.scope === 'QQHook.Runtime'); }