Files
SnowLuma/packages/bridge/tests/hook-manager.test.ts
T
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

515 lines
19 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { HookManager, shouldAutoLoadPid } from '../src/hook-manager';
import { PipeWatcher } from '../src/pipe-watcher';
import type { HookInjectResult, ManualMapHandle } from '../src/injector';
import type { BridgeManagerSink } from '../src/hook-manager';
import type { AccountLoginState } from '../src/qq-account';
import type { QqHookClient } from '../src/qq-hook-client';
import {
createLogger,
getLogLevel,
setLogLevel,
subscribeLogs,
type LogEntry,
} from '@snowluma/common/logger';
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
const flush = () => new Promise<void>((r) => setImmediate(r));
const previousLogLevel = getLogLevel();
function managerTrace(entries: LogEntry[]): LogEntry[] {
return entries.filter((entry) => entry.level === 'trace' && entry.scope === 'Hook');
}
function makeManager(opts: {
autoLoadOnDiscovery?: boolean;
processes?: number[];
onSessionsChanged?: () => void;
inject?: (pid: number) => Promise<HookInjectResult>;
detectAccounts?: (pids: readonly number[]) => Promise<AccountLoginState[]>;
}) {
let pids = opts.processes ?? [];
const live = new Set<number>();
const inject = vi.fn(
opts.inject ??
(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
);
const unload = vi.fn(async () => {});
const pipeWatcher = new PipeWatcher({
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
listLivePipes: async () => new Set(live),
intervalMs: 60_000, // disable internal timer; tests drive ticks manually
});
// FakeClient — never connected; just needs to satisfy EventEmitter +
// a minimal subset of QqHookClient for HookSession.tearDownClient().
const makeClient = vi.fn(() => {
const c = new EventEmitter() as EventEmitter & Partial<QqHookClient>;
(c as any).isClosed = false;
(c as any).isLoggedIn = false;
(c as any).getLoginState = () => ({ loggedIn: false, uin: '0', uinNumber: 0n });
(c as any).connectAll = async () => {
throw new Error('test: never really connect');
};
(c as any).close = () => {
(c as any).isClosed = true;
};
return c as unknown as QqHookClient;
});
const bridgeManager = {
onPacket: vi.fn(),
onHookLogin: vi.fn(),
onPidDisconnected: vi.fn(),
} as unknown as BridgeManagerSink;
const manager = new HookManager({
bridgeManager,
pipeWatcher,
injector: { inject, unload },
makeClient,
autoLoadOnDiscovery: opts.autoLoadOnDiscovery,
onSessionsChanged: opts.onSessionsChanged,
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
// Detection reads QQ's own data directories; tests stay off the host.
detectAccounts: opts.detectAccounts ?? (async () => []),
watchAccounts: () => () => {},
});
return {
manager,
inject,
pipeWatcher,
setProcesses: (next: number[]) => {
pids = next;
},
};
}
afterEach(() => {
setLogLevel(previousLogLevel);
});
describe('HookManager runtime TRACE', () => {
it('records discovery and process-gone facts while unchanged ticks stay silent', async () => {
const entries: LogEntry[] = [];
const sessionsChanged = vi.fn();
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({
autoLoadOnDiscovery: false,
processes: [],
onSessionsChanged: sessionsChanged,
});
try {
await ctx.pipeWatcher.start();
const baseline = managerTrace(entries).length;
await ctx.pipeWatcher.tickNow();
await ctx.pipeWatcher.tickNow();
expect(managerTrace(entries)).toHaveLength(baseline);
ctx.setProcesses([4242]);
await ctx.pipeWatcher.tickNow();
ctx.setProcesses([]);
await ctx.pipeWatcher.tickNow();
await flush();
const facts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_manager_fact '),
);
expect(facts.map((entry) => entry.message)).toEqual([
expect.stringContaining('hook_manager_fact event=process_discovered pid=4242'),
'hook_manager_fact event=process_gone pid=4242 tracked=true',
]);
expect(facts.every((entry) => entry.req !== undefined)).toBe(true);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
it('records bounded transient retries and recovery as separate attempts', async () => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject
.mockImplementationOnce(() => {
throw new Error(
'target process does not map /lib/x86_64-linux-gnu/libc.so.6 while resolving mmap',
);
})
.mockImplementationOnce(() => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
}));
try {
await ctx.pipeWatcher.start();
await flush();
await flush();
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
const attempts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_'),
);
expect(attempts.map((entry) => entry.message)).toEqual([
'hook_autoload_start pid=4242 attempt=1 maxAttempts=3',
expect.stringMatching(
/^hook_autoload_terminal pid=4242 attempt=1 outcome=failed reason=retry_pending error=.*elapsedMs=\d+$/,
),
'hook_autoload_start pid=4242 attempt=2 maxAttempts=3',
expect.stringMatching(
/^hook_autoload_terminal pid=4242 attempt=2 outcome=completed reason=recovered state=.*elapsedMs=\d+$/,
),
]);
expect(attempts[0]!.req).toBe(attempts[1]!.req);
expect(attempts[2]!.req).toBe(attempts[3]!.req);
expect(attempts[0]!.req).not.toBe(attempts[2]!.req);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
it('ends an in-flight auto-load as dropped when a manual load supersedes it', async () => {
const entries: LogEntry[] = [];
let resolveInjection!: (result: HookInjectResult) => void;
const injection = new Promise<HookInjectResult>((resolve) => {
resolveInjection = resolve;
});
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({
autoLoadOnDiscovery: true,
processes: [4242],
inject: () => injection,
});
try {
await ctx.pipeWatcher.start();
await flush();
const manualLoad = ctx.manager.loadProcess(4242);
resolveInjection({ method: 'loadModuleManual', handle: DUMMY_HANDLE });
await manualLoad;
await flush();
const attempts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_'),
);
expect(attempts.map((entry) => entry.message)).toEqual([
'hook_autoload_start pid=4242 attempt=1 maxAttempts=3',
expect.stringMatching(
/^hook_autoload_terminal pid=4242 attempt=1 outcome=dropped reason=superseded elapsedMs=\d+$/,
),
]);
expect(attempts[0]!.req).toBe(attempts[1]!.req);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
it.each([
{
name: 'permanent failure',
error: 'ptrace denied',
ticks: 1,
attempts: 1,
reason: 'permanent_failure',
},
{
name: 'retry exhaustion',
error: 'target process does not map /lib/libc.so.6 while resolving mmap',
ticks: 4,
attempts: 3,
reason: 'retry_exhausted',
},
])('records $name terminal', async ({ error, ticks, attempts, reason }) => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementation(() => {
throw new Error(error);
});
try {
await ctx.pipeWatcher.start();
await flush();
await flush();
for (let tick = 0; tick < ticks; tick += 1) {
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
}
const starts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_start '),
);
const terminals = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_terminal '),
);
expect(starts).toHaveLength(attempts);
expect(terminals).toHaveLength(attempts);
expect(terminals.at(-1)!.message).toContain(`reason=${reason}`);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
});
describe('HookManager.autoLoadOnDiscovery', () => {
it('does NOT inject on process-discovered when flag is off', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: false, processes: [4242] });
await ctx.pipeWatcher.start();
await flush();
expect(ctx.inject).not.toHaveBeenCalled();
ctx.manager.dispose();
});
it('injects every newly-discovered PID when flag is on', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
await ctx.pipeWatcher.start();
// session.load() is queued via a per-session promise chain; flush twice
// so the auto-load runs to completion before we assert.
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(1);
expect(ctx.inject).toHaveBeenCalledWith(4242);
// A second PID appearing mid-flight should also trigger an inject.
ctx.setProcesses([4242, 9999]);
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(2);
expect(ctx.inject).toHaveBeenLastCalledWith(9999);
ctx.manager.dispose();
});
it('swallows inject errors so the watcher keeps running', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementationOnce(() => {
throw new Error('ptrace denied');
});
await ctx.pipeWatcher.start();
await flush();
await flush();
// Second PID should still be auto-loaded — the failure on 4242 must
// not leak out of the listener and break the watcher's emit loop.
ctx.setProcesses([4242, 9999]);
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(2);
ctx.manager.dispose();
});
it('retries a transient libc mapping failure on the next watcher tick', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementationOnce(() => {
throw new Error(
'target process does not map /lib/x86_64-linux-gnu/libc.so.6 while resolving mmap',
);
});
await ctx.pipeWatcher.start();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(1);
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(2);
expect(ctx.inject).toHaveBeenLastCalledWith(4242);
ctx.manager.dispose();
});
it('does not retry permanent auto-load errors', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementation(() => {
throw new Error('ptrace denied');
});
await ctx.pipeWatcher.start();
await flush();
await flush();
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(1);
ctx.manager.dispose();
});
it('stops retrying a transient libc mapping failure after three attempts', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementation(() => {
throw new Error(
'target process does not map /lib/x86_64-linux-gnu/libc.so.6 while resolving mmap',
);
});
await ctx.pipeWatcher.start();
await flush();
await flush();
for (let tick = 0; tick < 4; tick += 1) {
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
}
expect(ctx.inject).toHaveBeenCalledTimes(3);
ctx.manager.dispose();
});
});
// `shouldAutoLoadPid` reads /proc/<pid>/cmdline directly, so we point
// it at a temporary directory that mimics the procfs layout. This lets
// the tests run on macOS / CI without a live Linux QQ process.
describe('shouldAutoLoadPid', () => {
let tmpProc: string;
const originalReadFileSync = fs.readFileSync;
const log = createLogger('test');
const originalPlatform = process.platform;
const originalAutoLoadAll = process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL;
beforeEach(() => {
tmpProc = fs.mkdtempSync(path.join(os.tmpdir(), 'hookmgr-proc-'));
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
delete process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL;
// Redirect readFileSync('/proc/<pid>/cmdline') to the tmp dir.
(fs.readFileSync as unknown as typeof fs.readFileSync) = ((
p: string,
...rest: unknown[]
) => {
const match = /^\/proc\/(\d+)\/cmdline$/.exec(p);
if (match) {
return originalReadFileSync(
path.join(tmpProc, match[1], 'cmdline'),
...(rest as [BufferEncoding]),
);
}
return originalReadFileSync(p, ...(rest as [BufferEncoding]));
}) as typeof fs.readFileSync;
});
afterEach(() => {
(fs.readFileSync as unknown as typeof fs.readFileSync) = originalReadFileSync;
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
if (originalAutoLoadAll === undefined) {
delete process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL;
} else {
process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL = originalAutoLoadAll;
}
fs.rmSync(tmpProc, { recursive: true, force: true });
});
function writeCmdline(pid: number, args: string[]): void {
const dir = path.join(tmpProc, String(pid));
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'cmdline'), args.join('\0') + '\0');
}
it('allows the QQ main process (no --type=)', () => {
writeCmdline(50, ['qq', '--no-sandbox']);
expect(shouldAutoLoadPid(50, log)).toBe(true);
});
it('rejects Electron zygotes', () => {
writeCmdline(59, ['/opt/QQ/qq', '--type=zygote', '--no-zygote-sandbox', '--no-sandbox']);
expect(shouldAutoLoadPid(59, log)).toBe(false);
});
it('rejects renderer/gpu/utility children', () => {
writeCmdline(70, ['/opt/QQ/qq', '--type=renderer']);
writeCmdline(71, ['/opt/QQ/qq', '--type=gpu-process']);
writeCmdline(72, [
'/opt/QQ/qq',
'--type=utility',
'--utility-sub-type=network.mojom.NetworkService',
]);
expect(shouldAutoLoadPid(70, log)).toBe(false);
expect(shouldAutoLoadPid(71, log)).toBe(false);
expect(shouldAutoLoadPid(72, log)).toBe(false);
});
it('allows everything if SNOWLUMA_HOOK_AUTOLOAD_ALL=1 (escape hatch)', () => {
writeCmdline(59, ['/opt/QQ/qq', '--type=zygote']);
process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL = '1';
expect(shouldAutoLoadPid(59, log)).toBe(true);
});
it('allows on non-linux platforms (filter is a Linux-only workaround)', () => {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
// No cmdline file needed — we return true before touching procfs.
expect(shouldAutoLoadPid(59, log)).toBe(true);
});
it('allows when /proc is unreadable (dead PID, permission issue)', () => {
expect(shouldAutoLoadPid(99999, log)).toBe(true);
});
});
describe('HookManager — signed-in account labelling', () => {
it('labels each process with the account detected on it', async () => {
const ctx = makeManager({
processes: [4242, 5151],
detectAccounts: async () => [
{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 },
{ uin: '10002', uid: 'u_b', nickname: 'Bob', pid: null },
],
});
try {
const list = await ctx.manager.listProcesses();
expect(list.map((info) => [info.pid, info.account])).toEqual([
[4242, { uin: '10001', nickname: 'Alice' }],
[5151, null],
]);
} finally {
ctx.manager.dispose();
}
});
it('leaves processes unlabelled when detection fails', async () => {
const ctx = makeManager({
processes: [4242],
detectAccounts: async () => {
throw new Error('login.db unreadable');
},
});
try {
const list = await ctx.manager.listProcesses();
expect(list[0]!.account).toBeNull();
} finally {
ctx.manager.dispose();
}
});
});