refactor(bridge): wire HookSession status through statusFor + shared reconcilers

Phase 2. Migrate the ~6 hand-copied status ternaries to the pure
statusFor() and collapse the pipe up/down orchestration into two shared
private reconcilers:

- reconcilePipeUp  ← onPipeUp + refresh's pipe-up branch
- reconcilePipeDown ← onPipeDown + refresh's pipe-down branch + the
  client 'close' handler (4 call sites, all were near-duplicates)

applyStatus() pushes the statusFor-derived status; loading/error stay
explicit. refreshInternal collapses to a one-line pipe poll. Behavior-
preserving except the intended drift fix (refresh-while-down + never
logged in now reports 'connecting', matching onPipeDown, not the old
spurious 'disconnected').

reconcilePipeDown short-circuits when !connected so a pipe-down tick
can't clobber a diagnostic error or emit a spurious status-changed on a
failed-connect/failed-load session — covered by two new regression
tests (caught in adversarial review). 55/55 green.
This commit is contained in:
DrSmoothl
2026-06-24 19:42:59 +08:00
parent 74c7e086c7
commit 1d2a56bcdb
2 changed files with 120 additions and 60 deletions
+75 -60
View File
@@ -4,6 +4,7 @@ import { EventEmitter } from 'events';
import { HookPacketClient } from './hook-packet-client';
import type { HookInjectResult } from './injector';
import type { QqHookClient, QqHookLoginState, QqHookPacket } from './qq-hook-client';
import { statusFor } from './hook-status';
import type { HookProcessInfo, HookProcessStatus } from './types';
export type HookSessionDeps = {
@@ -131,17 +132,7 @@ export class HookSession extends EventEmitter {
if (this.disposed) return;
void this.serialize(async () => {
if (this.disposed) return;
// Adopt a DLL that survived a SnowLuma restart.
if (!this.injected) {
this.injected = true;
if (!this._method) this._method = 'reconnect';
}
if (this.client?.isClosed) this.tearDownClient();
if (!this.connected) {
await this.attemptConnect();
} else {
this.setStatus(this.loggedIn ? 'online' : 'loaded', '');
}
await this.reconcilePipeUp();
}).catch(err => this.log.warn('onPipeUp failed: PID=%d err=%s', this.pid, errMsg(err)));
}
@@ -149,18 +140,7 @@ export class HookSession extends EventEmitter {
if (this.disposed) return;
void this.serialize(async () => {
if (this.disposed) return;
if (!this.connected) {
// Nothing to tear down; just keep status visible.
if (this.injected) this.setStatus('connecting', this._error);
return;
}
const wasLoggedIn = this.loggedIn;
this.tearDownClient();
this.setStatus(
this.injected ? (wasLoggedIn ? 'disconnected' : 'connecting') : 'available',
'',
);
if (wasLoggedIn) this.emit('disconnected', true);
this.reconcilePipeDown();
}).catch(err => this.log.warn('onPipeDown failed: PID=%d err=%s', this.pid, errMsg(err)));
}
@@ -227,10 +207,7 @@ export class HookSession extends EventEmitter {
this._method = this.injectResult.method;
}
}
this.setStatus(
this.connected ? (this.loggedIn ? 'online' : 'loaded') : 'connecting',
'',
);
this.applyStatus();
} catch (error) {
this._error = errMsg(error);
this.setStatus('error', this._error);
@@ -281,24 +258,14 @@ export class HookSession extends EventEmitter {
private async refreshInternal(): Promise<HookProcessInfo> {
this._error = '';
try {
const pipeUp = this.pipeWatcher.isPipeLive(this.pid);
if (pipeUp) {
// Adopt an unknown DLL the same way onPipeUp would.
if (!this.injected) {
this.injected = true;
this._method = this._method || 'reconnect';
}
if (this.client?.isClosed) this.tearDownClient();
if (!this.connected) {
await this.attemptConnect();
} else {
this.setStatus(this.loggedIn ? 'online' : 'loaded', '');
}
// Same pipe-up / pipe-down reconcilers the watcher drives, chosen by
// a fresh poll. This collapses what used to be a hand-copied pair of
// onPipeUp/onPipeDown bodies — and fixes the drift where the down
// branch reported 'disconnected' even for a never-logged-in session.
if (this.pipeWatcher.isPipeLive(this.pid)) {
await this.reconcilePipeUp();
} else {
const wasLoggedIn = this.loggedIn;
this.tearDownClient();
this.setStatus(this.injected ? 'disconnected' : 'available', '');
if (wasLoggedIn) this.emit('disconnected', true);
this.reconcilePipeDown();
}
} catch (error) {
this._error = errMsg(error);
@@ -308,6 +275,55 @@ export class HookSession extends EventEmitter {
return this.toInfo();
}
// ─────────────── settled-status reconcilers ───────────────
/** Push the settled status derived from the live flags. `wasLoggedIn`
* 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 {
this.setStatus(statusFor({
injected: this.injected,
connected: this.connected,
loggedIn: this.loggedIn,
wasLoggedIn,
}), error);
}
/** Pipe is up: adopt a DLL that survived a SnowLuma restart, drop a
* stale client, then (re)connect if needed or just settle the status.
* Shared by onPipeUp and refresh's pipe-up branch. */
private async reconcilePipeUp(): Promise<void> {
if (!this.injected) {
this.injected = true;
if (!this._method) this._method = 'reconnect';
}
if (this.client?.isClosed) this.tearDownClient();
if (!this.connected) {
await this.attemptConnect();
} else {
this.applyStatus();
}
}
/** Pipe is down (or the client closed): tear down, settle the status,
* and emit the disconnect notification iff we owed BridgeManager one
* (i.e. we had reached login). Shared by onPipeDown, refresh's pipe-down
* branch, and the client 'close' handler. */
private reconcilePipeDown(): void {
if (!this.connected) {
// Nothing live to tear down. A session that never connected can't owe
// a disconnect, and we must NOT clobber a diagnostic the failed
// connect/load already set — settle the status keeping `_error`, or
// (when not even injected) leave the status untouched entirely.
if (this.injected) this.applyStatus(this.loggedIn, this._error);
return;
}
const wasLoggedIn = this.loggedIn;
this.tearDownClient();
this.applyStatus(wasLoggedIn);
if (wasLoggedIn) this.emit('disconnected', true);
}
// ─────────────── client plumbing ───────────────
private async attemptConnect(): Promise<void> {
@@ -327,15 +343,19 @@ export class HookSession extends EventEmitter {
try {
await client.connectAll({ recv: true });
this.connected = true;
this.setStatus(client.isLoggedIn ? 'online' : 'loaded', '');
const loginState = client.getLoginState();
// handleLoginState owns the connected+loggedIn → 'online' (+ login
// emit) transition; defer to it so the status is set once. Otherwise
// we're connected-but-not-logged-in → 'loaded'.
if (loginState.loggedIn) this.handleLoginState(loginState);
else this.applyStatus();
this.log.info('pipe connected: PID=%d', this.pid);
} catch (error) {
this._error = errMsg(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.setStatus(this.injected ? 'connecting' : 'available', this._error);
this.applyStatus(false, this._error);
}
}
@@ -355,16 +375,7 @@ export class HookSession extends EventEmitter {
void this.serialize(async () => {
if (this.disposed) return;
if (this.client !== client) return;
const wasLoggedIn = this.loggedIn;
this.tearDownClient();
if (!this.injected) {
this.setStatus('available', '');
} else if (wasLoggedIn) {
this.setStatus('disconnected', '');
} else {
this.setStatus('connecting', '');
}
if (wasLoggedIn) this.emit('disconnected', true);
this.reconcilePipeDown();
}).catch(err => this.log.warn('close reconcile failed: PID=%d err=%s', this.pid, errMsg(err)));
});
}
@@ -388,10 +399,14 @@ export class HookSession extends EventEmitter {
this._uin = state.uin || state.uinNumber.toString();
this.loggedIn = state.loggedIn && isRealUin(this._uin);
if (this.loggedIn) {
this.setStatus('online', '');
} else if (this.connected) {
this.setStatus('loaded', this._error);
// Only the connected/logged-in states are ours to set here; when fully
// down we leave the status the teardown path already settled.
// Load-bearing invariant: `loggedIn ⇒ connected` (login can only be
// observed on a live client, and teardown clears `loggedIn` before
// `connected`), so statusFor lands 'online' here rather than the
// disconnected/connecting branch.
if (this.connected || this.loggedIn) {
this.applyStatus(wasLoggedIn, this.loggedIn ? '' : this._error);
}
if (!this.loggedIn || !this.sender) return;
@@ -347,6 +347,51 @@ describe('HookSession — process gone', () => {
});
});
describe('HookSession — pipe-down must not clobber a settled error', () => {
// Regression net for the phase-2 reconcilePipeDown !connected short-circuit:
// a pipe-down tick on a session that never reached a live connection must
// not wipe a diagnostic error or emit a spurious status-changed.
it('onPipeDown after a failed connect keeps the error and does not re-emit', async () => {
const ctx = makeSession({ pipeLive: true, clientFailsConnect: true });
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
expect(ctx.session.status).toBe('connecting');
expect(ctx.session.error).toBe('connect failed');
const statusSpy = vi.fn();
ctx.session.on('status-changed', statusSpy);
ctx.session.onPipeDown();
await flush();
expect(ctx.session.status).toBe('connecting');
expect(ctx.session.error).toBe('connect failed'); // not wiped to ''
expect(statusSpy).not.toHaveBeenCalled(); // no spurious emit
});
it('onPipeDown after a failed load leaves the error status intact', async () => {
const injector = {
inject: vi.fn(() => { throw new Error('inject boom'); }),
unload: vi.fn(),
};
const session = new HookSession(1234, {
injector,
makeClient: () => new FakeClient() as unknown as QqHookClient,
pipeWatcher: { isPipeLive: () => false },
});
await session.load();
expect(session.status).toBe('error');
expect(session.error).toBe('inject boom');
session.onPipeDown();
await flush();
expect(session.status).toBe('error'); // not flipped to 'available'
expect(session.error).toBe('inject boom'); // not wiped
});
});
describe('HookSession — packet forwarding', () => {
// These tests cover the field-rename + filter logic that used to live
// inside the deleted NtqqHandler.onHookPacket. Inlining it here means