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.
This commit is contained in:
DrSmoothl
2026-10-31 18:28:05 +08:00
parent fcbb2661d1
commit 8ecf41ccb7
4 changed files with 269 additions and 40 deletions
+54 -9
View File
@@ -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;
}
+58 -27
View File
@@ -563,6 +563,7 @@ export class QqHookClient extends EventEmitter {
private recvHelloResolver: Deferred<QqHookHello> | null = null;
private controlWriteChain: Promise<unknown> = Promise.resolve();
private closed = false;
private runtimeFailure: Error | null = null;
private loginState: QqHookLoginState = { loggedIn: false, uin: '0', uinNumber: 0n };
private readonly loginWaiters = new Set<Deferred<QqHookLoginState>>();
@@ -615,10 +616,7 @@ export class QqHookClient extends EventEmitter {
async waitForLogin({ timeoutMs = 0 } = {}): Promise<QqHookLoginState> {
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<QqHookHello> {
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<QqHookHello> {
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<void>((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<void>((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;
@@ -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<void> {
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<string | null>;
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<void>((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[] = [];
+79 -4
View File
@@ -85,6 +85,7 @@ function readSendFrame(payload: Buffer): {
function mockControlPipe(
pid: number,
onWrite: (socket: net.Socket, payload: Buffer) => void,
...startupFrames: Buffer[]
): ReturnType<typeof vi.spyOn> {
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');
}