SnowLuma v2.4.2 source snapshot
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
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
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,514 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user