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'); }