Merge pull request #29 from SnowLuma/dev
chore: promote dev → main (6abde40)
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import fs from 'fs';
|
||||
import type { BridgeManager } from '../bridge/manager';
|
||||
import type { PacketSink } from '../protocol/types';
|
||||
import { createLogger, type Logger } from '../utils/logger';
|
||||
@@ -169,7 +170,7 @@ export class HookManager {
|
||||
// injected without a human clicking "Load" in WebUI. Fire-and-forget:
|
||||
// failures are already captured inside loadInternal and surfaced via
|
||||
// the session's status field.
|
||||
if (this.autoLoadOnDiscovery) {
|
||||
if (this.autoLoadOnDiscovery && shouldAutoLoadPid(info.pid, this.log)) {
|
||||
void session.load().catch((err) => {
|
||||
this.log.warn('auto-load failed: PID=%d err=%s', info.pid, errMsg(err));
|
||||
});
|
||||
@@ -243,3 +244,39 @@ function defaultProcessName(): string {
|
||||
function errMsg(value: unknown): string {
|
||||
return value instanceof Error ? value.message : String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter for auto-load only. Returns false for Linux Electron child
|
||||
* processes (zygote/renderer/gpu/utility), which the native enumerator
|
||||
* mis-classifies as "main QQ" because their cmdline contains "qq" and
|
||||
* they transiently inherit wrapper.node via fork copy-on-write.
|
||||
*
|
||||
* Injecting into a zygote spawns the hook's resolver thread inside it;
|
||||
* every renderer Electron later forks then inherits that thread + the
|
||||
* partially-init'd hook state, which breaks QQ's IPC and login UI.
|
||||
*
|
||||
* Manual loadProcess() bypasses this gate — the operator is responsible
|
||||
* for picking the right PID from WebUI.
|
||||
*/
|
||||
export function shouldAutoLoadPid(pid: number, log: Logger): boolean {
|
||||
if (process.platform !== 'linux') return true;
|
||||
// Escape hatch: operators who explicitly want auto-load on every
|
||||
// enumerated PID can set this to opt out of the filter.
|
||||
if (process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL === '1') return true;
|
||||
|
||||
let cmdline: string;
|
||||
try {
|
||||
cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
||||
} catch {
|
||||
// Can't read cmdline (process gone, permission, etc.) — let the
|
||||
// existing load() path handle it; if the process is dead the load
|
||||
// will fail with a clear error.
|
||||
return true;
|
||||
}
|
||||
if (cmdline.includes('--type=')) {
|
||||
const type = (/--type=([^\0\s]+)/.exec(cmdline)?.[1]) ?? 'unknown';
|
||||
log.info('auto-load skip: PID=%d is an Electron child process (--type=%s)', pid, type);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { HookManager } from '../src/hook/hook-manager';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { HookManager, shouldAutoLoadPid } from '../src/hook/hook-manager';
|
||||
import { PipeWatcher } from '../src/hook/pipe-watcher';
|
||||
import type { ManualMapHandle } from '../src/hook/injector';
|
||||
import type { BridgeManager } from '../src/bridge/manager';
|
||||
import type { QqHookClient } from '../src/hook/qq-hook-client';
|
||||
import { createLogger } from '../src/utils/logger';
|
||||
|
||||
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
|
||||
const flush = () => new Promise<void>(r => setImmediate(r));
|
||||
@@ -102,3 +106,77 @@ describe('HookManager.autoLoadOnDiscovery', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user