Merge pull request #29 from SnowLuma/dev

chore: promote dev → main (6abde40)
This commit is contained in:
snowluma-bot[bot]
2026-05-13 16:23:42 +08:00
committed by GitHub
3 changed files with 118 additions and 3 deletions
+38 -1
View File
@@ -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;
}
+80 -2
View File
@@ -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.