From 3dca0eb6ede541fd369ea7234cc3c9e9c8ce91ba Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Fri, 14 Aug 2026 11:43:46 +0800 Subject: [PATCH] feat(diagnostics): add bounded runtime capture CLI Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .gitignore | 2 + package.json | 3 + tools/orgii-diagnostics/README.md | 68 +++ tools/orgii-diagnostics/cli.mjs | 126 +++++ tools/orgii-diagnostics/lib/args.mjs | 201 ++++++++ .../lib/process-snapshot.mjs | 450 ++++++++++++++++++ tools/orgii-diagnostics/lib/recorder.mjs | 234 +++++++++ tools/orgii-diagnostics/lib/report.mjs | 305 ++++++++++++ tools/orgii-diagnostics/lib/session-store.mjs | 237 +++++++++ tools/orgii-diagnostics/test/args.test.mjs | 71 +++ .../orgii-diagnostics/test/lifecycle.test.mjs | 113 +++++ .../test/process-snapshot.test.mjs | 85 ++++ tools/orgii-diagnostics/test/report.test.mjs | 84 ++++ 13 files changed, 1979 insertions(+) create mode 100644 tools/orgii-diagnostics/README.md create mode 100644 tools/orgii-diagnostics/cli.mjs create mode 100644 tools/orgii-diagnostics/lib/args.mjs create mode 100644 tools/orgii-diagnostics/lib/process-snapshot.mjs create mode 100644 tools/orgii-diagnostics/lib/recorder.mjs create mode 100644 tools/orgii-diagnostics/lib/report.mjs create mode 100644 tools/orgii-diagnostics/lib/session-store.mjs create mode 100644 tools/orgii-diagnostics/test/args.test.mjs create mode 100644 tools/orgii-diagnostics/test/lifecycle.test.mjs create mode 100644 tools/orgii-diagnostics/test/process-snapshot.test.mjs create mode 100644 tools/orgii-diagnostics/test/report.test.mjs diff --git a/.gitignore b/.gitignore index b4aafa471..e591074d9 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ newmain lib/ # Allow src/lib/ to be tracked (important utilities and wrappers) !src/lib/ +!tools/orgii-diagnostics/lib/ +!tools/orgii-diagnostics/lib/** *storybook.log # Orgii Session - Critical Excludes (auto-added) diff --git a/package.json b/package.json index aa8cd5098..11ee6524e 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "dev:frontend": "node scripts/dev/webpack-server.js", "dev:frontend:light": "ORGII_LIGHT_DEV=true FAST_DEV=true DEV_SOURCEMAPS=false node scripts/dev/webpack-server.js", "dev:cpu-monitor": "bash scripts/dev/cpu-monitor.sh", + "diag:process": "node tools/orgii-diagnostics/cli.mjs process", + "diag:memory": "node tools/orgii-diagnostics/cli.mjs memory", + "diag:test": "node --test tools/orgii-diagnostics/test/*.test.mjs", "build": "webpack --mode production", "download": "python3 scripts/tools/download.py", "download:sidecars": "python3 scripts/tools/download_sidecars.py", diff --git a/tools/orgii-diagnostics/README.md b/tools/orgii-diagnostics/README.md new file mode 100644 index 000000000..e94be53a0 --- /dev/null +++ b/tools/orgii-diagnostics/README.md @@ -0,0 +1,68 @@ +# ORGII 独立诊断工具 + +这是一个仅供开发排障使用的外部命令行工具。它不导入 ORGII 前端代码,不注册 Tauri 命令,也不进入 App 的生产运行路径。 + +## 快速开始 + +先启动 ORGII,再检查与当前工作区或 App 进程树有关的僵尸/疑似被系统接管进程: + +```bash +pnpm diag:process +``` + +如果同时运行了多个 ORGII 实例,请指定主进程: + +```bash +pnpm diag:process --pid 12345 +``` + +开始前台内存录制: + +```bash +pnpm diag:memory record --pid auto +``` + +默认每 15 秒采样一次,最多 720 个样本;每个样本最多保留 256 条进程明细,但总 RSS 仍按全部归属进程计算。录制期间可在另一个终端标记操作阶段并停止: + +```bash +pnpm diag:memory mark "连续打开并关闭 20 个会话" +pnpm diag:memory stop +``` + +也可以用 `Ctrl-C` 停止。检查状态或重新生成最近一次报告: + +```bash +pnpm diag:memory status +pnpm diag:memory report +``` + +常用边界参数: + +```bash +pnpm diag:memory record --interval 5 --duration 300 --max-samples 120 +``` + +## 产物与口径 + +默认产物写入被 Git 忽略的 `.orgii/diagnostics/sessions/<会话 ID>/`: + +- `session.json`:根进程身份、采样配置和停止原因。 +- `samples.ndjson`:逐次原始样本,写一条落盘一条,不在内存中累积。 +- `markers/`:操作阶段标记。 +- `report.json`:机器可读的完整报告。 +- `samples.csv`:每个进程、每次采样一行,便于画图。 +- `summary.md`:趋势、峰值和阶段标记摘要。 + +工具使用 PID 与进程启动时间共同标识进程实例。根 PID 退出或被复用时,录制会停止,不会把新进程的数据接到旧会话。 + +当前 macOS/Linux 采样指标是系统 `ps` 提供的 RSS、虚拟内存和 CPU。macOS WebKit 辅助进程通过 `launchctl print pid/<宿主 PID>` 做宿主范围归因,并再次核对用户、系统 WebKit 路径和角色。报告中的 RSS 总和适合看趋势,但共享页可能被重复计算,不能单独证明内存泄漏。 + +写入报告的命令行会截断到 300 个字符,并遮盖常见的 token、密码、API key 和 URL 凭据参数。诊断产物仍可能包含本机路径及业务进程名称,请在对外分享前复核。 + +## 状态与失败行为 + +录制生命周期为 `idle → recording → ready`。停止、达到时长/样本上限、根进程退出和连续三次采样失败都会进入收尾并生成报告。 + +若记录器被强制终止,活动状态会标为旧会话;下一次 `record` 会按记录器 PID 与启动时间确认它确实已退出,再取得新的录制所有权。`mark` 和 `stop` 遇到旧状态会拒绝操作。外部 `stop` 只写入当前会话的停止请求文件,不向记录器 PID 发信号,因而没有 PID 复用误杀窗口。 + +工具不会自动结束它发现的进程。`diag:process` 给出的是收窄后的审计结果;确认无用后再使用对应进程自己的退出方式处理。 diff --git a/tools/orgii-diagnostics/cli.mjs b/tools/orgii-diagnostics/cli.mjs new file mode 100644 index 000000000..0b9ed94a2 --- /dev/null +++ b/tools/orgii-diagnostics/cli.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node + +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { CliUsageError, parseCliArgs, usage } from "./lib/args.mjs"; +import { auditProcesses } from "./lib/process-snapshot.mjs"; +import { recordMemorySession } from "./lib/recorder.mjs"; +import { generateReports } from "./lib/report.mjs"; +import { + ActiveSessionError, + addMarker, + inspectActiveState, + requestStop, + resolveDiagnosticPaths, + resolveReportSessionDir, +} from "./lib/session-store.mjs"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../.." +); + +function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return "不可用"; + return `${(bytes / 1024 ** 2).toFixed(1)} MiB`; +} + +function printProcessAudit(audit) { + process.stdout.write(`进程审计:${audit.capturedAt}\n`); + if (audit.root) { + process.stdout.write(`ORGII 主进程:PID ${audit.root.pid}\n`); + process.stdout.write(`相关进程:${audit.relatedProcesses.length}\n`); + } else { + process.stdout.write(`ORGII 主进程:未找到(${audit.warning})\n`); + for (const candidate of audit.candidates ?? []) { + process.stdout.write( + `- 候选 PID ${candidate.pid}:${candidate.command}\n` + ); + } + } + if (audit.findings.length === 0) { + process.stdout.write( + "未发现与当前工作区/ORGII 进程树相关的僵尸或被接管进程。\n" + ); + return; + } + process.stdout.write(`发现 ${audit.findings.length} 项需要检查:\n`); + for (const finding of audit.findings) { + process.stdout.write( + `- [${finding.kind}] PID ${finding.pid},PPID ${finding.parentPid}:${finding.reason}\n ${finding.command}\n` + ); + } +} + +async function runMemoryCommand(parsed) { + const paths = resolveDiagnosticPaths(repoRoot, parsed); + if (parsed.subcommand === "record") { + return recordMemorySession({ ...parsed, repoRoot }); + } + if (parsed.subcommand === "mark") { + const { active, marker } = await addMarker(paths.activePath, parsed.label); + process.stdout.write(`已标记会话 ${active.sessionId}:${marker.label}\n`); + return; + } + if (parsed.subcommand === "stop") { + const { active } = await requestStop(paths.activePath); + process.stdout.write( + `已请求停止会话 ${active.sessionId},正在生成报告。\n` + ); + return; + } + if (parsed.subcommand === "status") { + const inspection = await inspectActiveState(paths.activePath); + if (inspection.state === "idle") { + process.stdout.write("当前没有诊断录制会话。\n"); + return; + } + process.stdout.write( + `${inspection.state === "recording" ? "正在录制" : "发现中断的旧会话"}:${inspection.active.sessionId}\n` + + `记录器 PID:${inspection.active.recorder.pid}\n` + + `根进程 PID:${inspection.active.rootProcess.pid}(${inspection.rootLive ? "仍在运行" : "已退出或被替换"})\n` + + `产物目录:${inspection.active.sessionDir}\n` + ); + return; + } + const sessionDir = await resolveReportSessionDir(paths, parsed.sessionPath); + const report = await generateReports(sessionDir); + process.stdout.write( + `报告已生成:${report.files.markdown}\n` + + `有效样本:${report.summary.usableSampleCount}\n` + + `RSS 变化:${formatBytes(report.summary.deltaRssBytes)}\n` + + `判断:${report.summary.verdict}\n` + ); +} + +async function main() { + const parsed = parseCliArgs(process.argv.slice(2)); + if (parsed.command === "help") { + process.stdout.write(`${usage()}\n`); + return; + } + if (parsed.command === "process") { + const audit = await auditProcesses({ + requestedPid: parsed.pid, + workspaceRoot: repoRoot, + }); + if (parsed.json) { + process.stdout.write(`${JSON.stringify(audit, null, 2)}\n`); + } else { + printProcessAudit(audit); + } + return; + } + await runMemoryCommand(parsed); +} + +main().catch((error) => { + const prefix = error instanceof CliUsageError ? "用法错误" : "诊断失败"; + process.stderr.write(`${prefix}:${error.message}\n`); + if (error instanceof CliUsageError) process.stderr.write(`\n${usage()}\n`); + if (error instanceof ActiveSessionError) { + process.stderr.write("未执行任何可能影响其他进程的操作。\n"); + } + process.exitCode = 1; +}); diff --git a/tools/orgii-diagnostics/lib/args.mjs b/tools/orgii-diagnostics/lib/args.mjs new file mode 100644 index 000000000..8e0737b46 --- /dev/null +++ b/tools/orgii-diagnostics/lib/args.mjs @@ -0,0 +1,201 @@ +export class CliUsageError extends Error { + constructor(message) { + super(message); + this.name = "CliUsageError"; + } +} + +const MEMORY_SUBCOMMANDS = new Set([ + "record", + "mark", + "stop", + "status", + "report", +]); + +function takeValue(argv, index, option) { + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new CliUsageError(`${option} 需要一个值`); + } + return value; +} + +function parsePositiveNumber(raw, option, { integer = false } = {}) { + const value = Number(raw); + if ( + !Number.isFinite(value) || + value <= 0 || + (integer && !Number.isInteger(value)) + ) { + throw new CliUsageError(`${option} 必须是正${integer ? "整数" : "数"}`); + } + return value; +} + +function parsePid(raw) { + if (raw === "auto") return raw; + return parsePositiveNumber(raw, "--pid", { integer: true }); +} + +function parseOptions(argv, allowed) { + const options = {}; + const positionals = []; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith("--")) { + positionals.push(token); + continue; + } + if (!allowed.has(token)) { + throw new CliUsageError(`不支持的参数:${token}`); + } + if (token === "--json") { + options.json = true; + continue; + } + + const value = takeValue(argv, index, token); + index += 1; + switch (token) { + case "--pid": + options.pid = parsePid(value); + break; + case "--interval": + options.intervalSeconds = parsePositiveNumber(value, token); + break; + case "--max-samples": + options.maxSamples = parsePositiveNumber(value, token, { + integer: true, + }); + break; + case "--duration": + options.durationSeconds = parsePositiveNumber(value, token); + break; + case "--output": + options.outputRoot = value; + break; + case "--state-root": + options.stateRoot = value; + break; + default: + throw new CliUsageError(`尚未实现的参数:${token}`); + } + } + return { options, positionals }; +} + +export function parseCliArgs(argv) { + const [command, ...rest] = argv; + if ( + !command || + command === "help" || + command === "--help" || + command === "-h" + ) { + return { command: "help" }; + } + + if (command === "process") { + const { options, positionals } = parseOptions( + rest, + new Set(["--pid", "--json"]) + ); + if (positionals.length > 0) { + throw new CliUsageError( + `process 不接受位置参数:${positionals.join(" ")}` + ); + } + return { + command, + pid: options.pid ?? "auto", + json: options.json ?? false, + }; + } + + if (command !== "memory") { + throw new CliUsageError(`未知命令:${command}`); + } + + const [subcommand, ...subcommandArgs] = rest; + if (!MEMORY_SUBCOMMANDS.has(subcommand)) { + throw new CliUsageError(`未知 memory 子命令:${subcommand ?? "(缺失)"}`); + } + + if (subcommand === "record") { + const { options, positionals } = parseOptions( + subcommandArgs, + new Set([ + "--pid", + "--interval", + "--max-samples", + "--duration", + "--output", + "--state-root", + ]) + ); + if (positionals.length > 0) { + throw new CliUsageError( + `memory record 不接受位置参数:${positionals.join(" ")}` + ); + } + return { + command, + subcommand, + pid: options.pid ?? "auto", + intervalSeconds: options.intervalSeconds ?? 15, + maxSamples: options.maxSamples ?? 720, + durationSeconds: options.durationSeconds, + outputRoot: options.outputRoot, + stateRoot: options.stateRoot, + }; + } + + if (subcommand === "mark") { + const { options, positionals } = parseOptions( + subcommandArgs, + new Set(["--state-root"]) + ); + const label = positionals.join(" ").trim(); + if (!label) throw new CliUsageError("memory mark 需要一段标记文字"); + if (label.length > 200) + throw new CliUsageError("标记文字不能超过 200 个字符"); + return { command, subcommand, label, stateRoot: options.stateRoot }; + } + + const { options, positionals } = parseOptions( + subcommandArgs, + new Set(["--state-root"]) + ); + if (subcommand !== "report" && positionals.length > 0) { + throw new CliUsageError( + `memory ${subcommand} 不接受位置参数:${positionals.join(" ")}` + ); + } + if (subcommand === "report" && positionals.length > 1) { + throw new CliUsageError("memory report 最多接受一个会话目录"); + } + return { + command, + subcommand, + sessionPath: positionals[0], + stateRoot: options.stateRoot, + }; +} + +export function usage() { + return `ORGII 独立诊断工具 + +用法: + pnpm diag:process [--pid auto|PID] [--json] + pnpm diag:memory record [--pid auto|PID] [--interval 秒] [--max-samples 数量] + [--duration 秒] [--output 目录] [--state-root 目录] + pnpm diag:memory mark "操作阶段说明" [--state-root 目录] + pnpm diag:memory stop [--state-root 目录] + pnpm diag:memory status [--state-root 目录] + pnpm diag:memory report [会话目录] [--state-root 目录] + +说明:record 是前台录制;可从另一个终端执行 mark / stop。默认产物位于 +.orgii/diagnostics/,不会接入或修改 App 的 UI、IPC 与生产运行路径。`; +} diff --git a/tools/orgii-diagnostics/lib/process-snapshot.mjs b/tools/orgii-diagnostics/lib/process-snapshot.mjs new file mode 100644 index 000000000..b07d75df7 --- /dev/null +++ b/tools/orgii-diagnostics/lib/process-snapshot.mjs @@ -0,0 +1,450 @@ +import { execFile } from "node:child_process"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const PS_PATH = "/bin/ps"; +const LAUNCHCTL_PATH = "/bin/launchctl"; +const PROCESS_COMMAND_TIMEOUT_MS = 5_000; +const PROCESS_COMMAND_MAX_BUFFER = 16 * 1024 * 1024; +const MAX_REPORTED_PROCESSES = 256; +const MAX_AUDIT_FINDINGS = 200; +const MAX_REPORTED_COMMAND_LENGTH = 300; + +export class ProcessResolutionError extends Error { + constructor(message, candidates = []) { + super(message); + this.name = "ProcessResolutionError"; + this.candidates = candidates; + } +} + +function parseInteger(raw) { + const value = Number.parseInt(raw, 10); + return Number.isFinite(value) ? value : 0; +} + +export function redactProcessCommand(command) { + const redacted = command + .replace( + /(--?(?:api[-_]?key|access[-_]?token|refresh[-_]?token|token|password|passwd|secret|authorization|auth)(?:=|\s+))(?:("[^"]*")|('[^']*')|\S+)/gi, + "$1[REDACTED]" + ) + .replace(/(https?:\/\/[^\s/:]+:)[^\s@]+@/gi, "$1[REDACTED]@"); + return redacted.length > MAX_REPORTED_COMMAND_LENGTH + ? `${redacted.slice(0, MAX_REPORTED_COMMAND_LENGTH)}…` + : redacted; +} + +export function parsePsOutput(output) { + const rows = []; + const pattern = + /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+([A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(.+)$/; + for (const line of output.split("\n")) { + const match = line.match(pattern); + if (!match) continue; + rows.push({ + pid: parseInteger(match[1]), + parentPid: parseInteger(match[2]), + processGroupId: parseInteger(match[3]), + userId: parseInteger(match[4]), + state: match[5], + startToken: match[6], + rssBytes: parseInteger(match[7]) * 1024, + virtualBytes: parseInteger(match[8]) * 1024, + cpuPercent: Number.parseFloat(match[9]) || 0, + command: match[10], + }); + } + return rows; +} + +export async function collectProcessTable() { + if (process.platform === "win32") { + throw new Error("当前版本的独立诊断工具暂不支持 Windows 进程采样"); + } + const { stdout } = await execFileAsync( + PS_PATH, + ["-axo", "pid=,ppid=,pgid=,uid=,stat=,lstart=,rss=,vsz=,%cpu=,command="], + { + encoding: "utf8", + env: { ...process.env, LC_ALL: "C" }, + maxBuffer: PROCESS_COMMAND_MAX_BUFFER, + timeout: PROCESS_COMMAND_TIMEOUT_MS, + } + ); + return parsePsOutput(stdout); +} + +function executableBasename(command) { + const executable = command.trim().split(/\s+/, 1)[0] ?? ""; + return path.basename(executable).toLowerCase(); +} + +export function isOrgiiRootCandidate(row, workspaceRoot) { + const basename = executableBasename(row.command); + const isRootName = + basename === "org2" || + basename === "orgii" || + /(?:^|\/)(?:org2|orgii)(?:\s|$)/i.test(row.command); + const isHelper = /\bhelper\b/i.test(row.command); + if (!isRootName || isHelper) return false; + if (!workspaceRoot) return true; + const normalizedRoot = `${path.resolve(workspaceRoot)}${path.sep}`; + return ( + row.command.includes(normalizedRoot) || + row.command.includes("/Contents/MacOS/") + ); +} + +export function resolveRootProcess(rows, requestedPid, workspaceRoot) { + if (requestedPid !== "auto") { + const selected = rows.find((row) => row.pid === requestedPid); + if (!selected) { + throw new ProcessResolutionError( + `找不到 PID ${requestedPid};它可能已经退出` + ); + } + return selected; + } + + const candidates = rows.filter((row) => + isOrgiiRootCandidate(row, workspaceRoot) + ); + if (candidates.length === 1) return candidates[0]; + if (candidates.length === 0) { + throw new ProcessResolutionError("没有找到正在运行的 ORGII 主进程"); + } + throw new ProcessResolutionError( + "找到多个 ORGII 主进程,请用 --pid 明确指定", + candidates + ); +} + +export function descendantDepth(pid, rootPid, rowsOrIndex) { + if (pid === rootPid) return 0; + const byPid = + rowsOrIndex instanceof Map + ? rowsOrIndex + : new Map(rowsOrIndex.map((row) => [row.pid, row])); + const seen = new Set(); + let currentPid = pid; + let depth = 0; + while (!seen.has(currentPid)) { + seen.add(currentPid); + const current = byPid.get(currentPid); + if (!current) return undefined; + depth += 1; + if (current.parentPid === rootPid) return depth; + if (current.parentPid <= 1) return undefined; + currentPid = current.parentPid; + } + return undefined; +} + +export function parseLaunchctlWebKitServices(output) { + const rolesByPid = new Map(); + for (const line of output.split("\n")) { + const match = line.match( + /^\s*(\d+)\s+-\s+(com\.apple\.WebKit\.(?:WebContent|GPU|Networking)(?:\.[^\s]+)?)\s*$/ + ); + if (!match || match[1] === "0") continue; + const service = match[2]; + const role = service.includes("WebContent") + ? "renderer" + : service.includes("GPU") + ? "gpu" + : "network"; + rolesByPid.set(parseInteger(match[1]), role); + } + return rolesByPid; +} + +async function collectOwnedWebKitServices(rootPid) { + if (process.platform !== "darwin") { + return { rolesByPid: new Map(), attribution: "complete" }; + } + try { + const { stdout } = await execFileAsync( + LAUNCHCTL_PATH, + ["print", `pid/${rootPid}`], + { + encoding: "utf8", + maxBuffer: PROCESS_COMMAND_MAX_BUFFER, + timeout: PROCESS_COMMAND_TIMEOUT_MS, + } + ); + return { + rolesByPid: parseLaunchctlWebKitServices(stdout), + attribution: "complete", + }; + } catch (error) { + return { + rolesByPid: new Map(), + attribution: "partial", + warning: `无法读取宿主 WebKit 服务:${error.message}`, + }; + } +} + +function isTrustedWebKitProcess(row, role, rootUserId) { + const expectedName = + role === "renderer" + ? "com.apple.WebKit.WebContent" + : role === "gpu" + ? "com.apple.WebKit.GPU" + : "com.apple.WebKit.Networking"; + return ( + row.userId === rootUserId && + row.command.includes("/System/Library/Frameworks/WebKit.framework/") && + row.command.includes(`/XPCServices/${expectedName}.xpc/`) && + row.command.includes(`/MacOS/${expectedName}`) + ); +} + +function descendantRole(row) { + const lower = row.command.toLowerCase(); + if ( + /\b(zsh|bash|fish|sh|pwsh|powershell)\b/.test(lower) || + lower.includes("terminal") + ) { + return "terminal"; + } + if ( + [ + "claude", + "codex", + "cursor", + "qoder", + "opencode", + "gemini", + "kiro", + "trae", + ].some((name) => lower.includes(name)) + ) { + return "agent_cli"; + } + return "tool"; +} + +export async function collectOwnedProcessSnapshot(rootIdentity, { rows } = {}) { + const processRows = rows ?? (await collectProcessTable()); + const root = processRows.find((row) => row.pid === rootIdentity.pid); + if (!root || root.startToken !== rootIdentity.startToken) { + return { + status: "root_exited", + capturedAt: new Date().toISOString(), + measurement: "resident_set_sum", + attribution: "partial", + processes: [], + totalRssBytes: 0, + totalVirtualBytes: 0, + skippedPids: [], + }; + } + + const webKit = await collectOwnedWebKitServices(root.pid); + const included = new Map(); + const rowsByPid = new Map(processRows.map((row) => [row.pid, row])); + included.set(root.pid, { + row: root, + role: "backend", + relation: "root", + depth: 0, + }); + for (const row of processRows) { + const depth = descendantDepth(row.pid, root.pid, rowsByPid); + if (depth && !included.has(row.pid)) { + included.set(row.pid, { + row, + role: descendantRole(row), + relation: "descendant", + depth, + }); + } + } + + const skippedPids = []; + for (const [pid, role] of webKit.rolesByPid) { + const row = processRows.find((candidate) => candidate.pid === pid); + if (!row || !isTrustedWebKitProcess(row, role, root.userId)) { + skippedPids.push(pid); + continue; + } + included.set(pid, { + row, + role, + relation: "owned_webkit", + depth: undefined, + }); + } + + const allProcesses = [...included.values()] + .map(({ row, role, relation, depth }) => ({ + pid: row.pid, + parentPid: row.parentPid, + processGroupId: row.processGroupId, + processInstanceId: `${row.pid}:${row.startToken}`, + startToken: row.startToken, + state: row.state, + role, + relation, + ...(depth === undefined ? {} : { depth }), + rssBytes: row.rssBytes, + virtualBytes: row.virtualBytes, + cpuPercent: row.cpuPercent, + command: redactProcessCommand(row.command), + })) + .sort((left, right) => { + if (left.relation === "root") return -1; + if (right.relation === "root") return 1; + return right.rssBytes - left.rssBytes || left.pid - right.pid; + }); + const processes = allProcesses.slice(0, MAX_REPORTED_PROCESSES); + const roleRssBytes = {}; + for (const item of allProcesses) { + roleRssBytes[item.role] = (roleRssBytes[item.role] ?? 0) + item.rssBytes; + } + + return { + status: "ok", + capturedAt: new Date().toISOString(), + measurement: "resident_set_sum", + attribution: + webKit.attribution === "complete" && skippedPids.length === 0 + ? "complete" + : "partial", + ...(webKit.warning ? { warning: webKit.warning } : {}), + processes, + omittedProcessCount: Math.max(0, allProcesses.length - processes.length), + roleRssBytes, + zombieProcessCount: allProcesses.filter((item) => item.state.includes("Z")) + .length, + totalRssBytes: allProcesses.reduce( + (total, item) => total + item.rssBytes, + 0 + ), + totalVirtualBytes: allProcesses.reduce( + (total, item) => total + item.virtualBytes, + 0 + ), + skippedPids, + }; +} + +export function auditProcessTable( + rows, + root, + workspaceRoot, + ownedWebKitPids = new Set() +) { + const rootPid = root?.pid; + const normalizedRoot = workspaceRoot + ? `${path.resolve(workspaceRoot)}${path.sep}` + : undefined; + const rowsByPid = new Map(rows.map((row) => [row.pid, row])); + const related = root + ? rows.filter( + (row) => + row.pid === rootPid || + descendantDepth(row.pid, rootPid, rowsByPid) !== undefined + ) + : []; + const relatedPids = new Set(related.map((row) => row.pid)); + const findings = []; + + for (const row of rows) { + const workspaceRelated = + normalizedRoot && row.command.includes(normalizedRoot); + const rootRelated = relatedPids.has(row.pid); + if (row.state.includes("Z") && (rootRelated || workspaceRelated)) { + findings.push({ + kind: "zombie", + severity: "actionable", + pid: row.pid, + parentPid: row.parentPid, + command: redactProcessCommand(row.command), + reason: "进程已退出但父进程尚未回收退出状态", + }); + continue; + } + if ( + row.parentPid === 1 && + workspaceRelated && + row.pid !== rootPid && + !ownedWebKitPids.has(row.pid) + ) { + findings.push({ + kind: "adopted_workspace_process", + severity: "review", + pid: row.pid, + parentPid: row.parentPid, + command: redactProcessCommand(row.command), + reason: "工作区相关进程已被系统 init 进程接管,需核对是否仍有用途", + }); + } + } + + return { + root: root + ? { + pid: root.pid, + parentPid: root.parentPid, + startToken: root.startToken, + command: redactProcessCommand(root.command), + } + : null, + relatedProcesses: related.slice(0, MAX_REPORTED_PROCESSES).map((row) => ({ + pid: row.pid, + parentPid: row.parentPid, + state: row.state, + rssBytes: row.rssBytes, + command: redactProcessCommand(row.command), + })), + omittedRelatedProcessCount: Math.max( + 0, + related.length - MAX_REPORTED_PROCESSES + ), + findings: findings.slice(0, MAX_AUDIT_FINDINGS), + omittedFindingCount: Math.max(0, findings.length - MAX_AUDIT_FINDINGS), + }; +} + +export async function auditProcesses({ requestedPid, workspaceRoot }) { + const rows = await collectProcessTable(); + let root; + let resolutionWarning; + let resolutionCandidates = []; + try { + root = resolveRootProcess(rows, requestedPid, workspaceRoot); + } catch (error) { + if (!(error instanceof ProcessResolutionError)) throw error; + resolutionWarning = error.message; + resolutionCandidates = error.candidates.map((candidate) => ({ + pid: candidate.pid, + startToken: candidate.startToken, + command: redactProcessCommand(candidate.command), + })); + } + + const webKit = root + ? await collectOwnedWebKitServices(root.pid) + : { rolesByPid: new Map(), attribution: "unavailable" }; + return { + schemaVersion: 1, + capturedAt: new Date().toISOString(), + platform: process.platform, + status: root ? "ok" : "app_not_found", + ...(resolutionWarning ? { warning: resolutionWarning } : {}), + ...(resolutionCandidates.length > 0 + ? { candidates: resolutionCandidates } + : {}), + webKitAttribution: webKit.attribution, + ...auditProcessTable( + rows, + root, + workspaceRoot, + new Set(webKit.rolesByPid.keys()) + ), + }; +} diff --git a/tools/orgii-diagnostics/lib/recorder.mjs b/tools/orgii-diagnostics/lib/recorder.mjs new file mode 100644 index 000000000..98b992ac8 --- /dev/null +++ b/tools/orgii-diagnostics/lib/recorder.mjs @@ -0,0 +1,234 @@ +import { watch } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import path from "node:path"; + +import { + collectOwnedProcessSnapshot, + collectProcessTable, + redactProcessCommand, + resolveRootProcess, +} from "./process-snapshot.mjs"; +import { generateReports } from "./report.mjs"; +import { + appendJsonLine, + claimActiveSession, + createSessionId, + readStopRequest, + removeActiveStateIfOwned, + resolveDiagnosticPaths, + writeJsonAtomic, +} from "./session-store.mjs"; + +function formatMiB(bytes) { + return `${(bytes / 1024 ** 2).toFixed(1)} MiB`; +} + +function createInterruptibleWait() { + let wake; + let timer; + let watcher; + return { + observe(sessionDir) { + watcher = watch(sessionDir, { encoding: "utf8" }, (_event, filename) => { + if (filename === "stop-request.json") wake?.(); + }); + watcher.on("error", () => wake?.()); + }, + wait(milliseconds) { + return new Promise((resolve) => { + wake = () => { + if (timer) clearTimeout(timer); + timer = undefined; + wake = undefined; + resolve(); + }; + timer = setTimeout(wake, milliseconds); + }); + }, + interrupt() { + wake?.(); + }, + close() { + if (timer) clearTimeout(timer); + timer = undefined; + wake = undefined; + watcher?.close(); + watcher = undefined; + }, + }; +} + +export async function recordMemorySession(options) { + const paths = resolveDiagnosticPaths(options.repoRoot, options); + const initialRows = await collectProcessTable(); + const root = resolveRootProcess(initialRows, options.pid, options.repoRoot); + const recorder = initialRows.find((row) => row.pid === process.pid); + if (!recorder) throw new Error("无法读取诊断记录器自身的进程身份"); + + const sessionId = createSessionId(); + const sessionDir = path.join(paths.outputRoot, sessionId); + const rootIdentity = { pid: root.pid, startToken: root.startToken }; + const active = { + schemaVersion: 1, + sessionId, + sessionDir, + startedAt: new Date().toISOString(), + recorder: { pid: recorder.pid, startToken: recorder.startToken }, + rootProcess: rootIdentity, + }; + + const session = { + schemaVersion: 1, + sessionId, + state: "recording", + platform: process.platform, + startedAt: active.startedAt, + rootProcess: { + ...rootIdentity, + parentPid: root.parentPid, + command: redactProcessCommand(root.command), + }, + recorder: active.recorder, + config: { + intervalSeconds: options.intervalSeconds, + maxSamples: options.maxSamples, + ...(options.durationSeconds + ? { durationSeconds: options.durationSeconds } + : {}), + measurement: "resident_set_sum", + }, + }; + const waiter = createInterruptibleWait(); + let requestedStopReason; + const requestSignalStop = (signal) => { + requestedStopReason ??= `signal_${signal.toLowerCase()}`; + waiter.interrupt(); + }; + const signalHandlers = new Map( + ["SIGINT", "SIGTERM"].map((signal) => [ + signal, + () => requestSignalStop(signal), + ]) + ); + for (const [signal, handler] of signalHandlers) process.on(signal, handler); + + let stopReason = "unknown"; + let sequence = 0; + let consecutiveErrors = 0; + let claimed = false; + let fatalError; + let report; + const startedAtMs = Date.now(); + const samplesPath = path.join(sessionDir, "samples.ndjson"); + + try { + await claimActiveSession(paths, active); + claimed = true; + await mkdir(sessionDir, { recursive: true }); + waiter.observe(sessionDir); + await writeJsonAtomic(path.join(sessionDir, "session.json"), session); + process.stdout.write( + `录制已开始:${sessionId}\n根进程:PID ${root.pid}\n产物目录:${sessionDir}\n` + ); + + while (sequence < options.maxSamples) { + const stopRequest = await readStopRequest(sessionDir, sessionId); + if (stopRequest) { + stopReason = "external_stop"; + break; + } + if (requestedStopReason) { + stopReason = requestedStopReason; + break; + } + if ( + options.durationSeconds && + Date.now() - startedAtMs >= options.durationSeconds * 1000 && + sequence > 0 + ) { + stopReason = "duration_reached"; + break; + } + + sequence += 1; + try { + const sample = await collectOwnedProcessSnapshot(rootIdentity); + const record = { schemaVersion: 1, sequence, ...sample }; + await appendJsonLine(samplesPath, record); + if (sample.status === "root_exited") { + stopReason = "root_process_exited_or_replaced"; + break; + } + consecutiveErrors = 0; + process.stdout.write( + `样本 ${sequence}/${options.maxSamples}:${formatMiB(sample.totalRssBytes)},${sample.processes.length} 个进程,归因 ${sample.attribution}\n` + ); + } catch (error) { + consecutiveErrors += 1; + await appendJsonLine(samplesPath, { + schemaVersion: 1, + sequence, + capturedAt: new Date().toISOString(), + status: "sample_error", + error: error.message, + processes: [], + }); + process.stderr.write(`样本 ${sequence} 失败:${error.message}\n`); + if (consecutiveErrors >= 3) { + stopReason = "three_consecutive_sample_errors"; + break; + } + } + + const stopRequestAfterSample = await readStopRequest( + sessionDir, + sessionId + ); + if (stopRequestAfterSample) { + stopReason = "external_stop"; + break; + } + if (requestedStopReason) { + stopReason = requestedStopReason; + break; + } + if (sequence >= options.maxSamples) { + stopReason = "sample_limit_reached"; + break; + } + await waiter.wait(options.intervalSeconds * 1000); + } + } catch (error) { + fatalError = error; + if (claimed) stopReason = "recorder_error"; + } finally { + waiter.close(); + for (const [signal, handler] of signalHandlers) + process.off(signal, handler); + if (claimed) { + try { + const stopRequest = await readStopRequest(sessionDir, sessionId); + if (stopRequest && stopReason !== "recorder_error") + stopReason = "external_stop"; + session.state = fatalError ? "failed" : "ready"; + session.endedAt = new Date().toISOString(); + session.stopReason = stopReason; + session.sampleCount = sequence; + if (fatalError) session.error = fatalError.message; + await writeJsonAtomic(path.join(sessionDir, "session.json"), session); + report = await generateReports(sessionDir); + } catch (finalizeError) { + fatalError ??= finalizeError; + } finally { + await removeActiveStateIfOwned(paths.activePath, sessionId); + } + } + } + + if (fatalError) throw fatalError; + if (!report) throw new Error("诊断录制未生成报告"); + process.stdout.write( + `录制已结束:${stopReason}\n报告:${report.files.markdown}\n` + ); + return report; +} diff --git a/tools/orgii-diagnostics/lib/report.mjs b/tools/orgii-diagnostics/lib/report.mjs new file mode 100644 index 000000000..f43534247 --- /dev/null +++ b/tools/orgii-diagnostics/lib/report.mjs @@ -0,0 +1,305 @@ +import { readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { writeJsonAtomic } from "./session-store.mjs"; + +function parseJsonLines(text) { + const values = []; + for (const [index, line] of text.split("\n").entries()) { + if (!line.trim()) continue; + try { + values.push(JSON.parse(line)); + } catch (error) { + values.push({ + status: "invalid_sample", + sequence: index + 1, + error: `样本行无法解析:${error.message}`, + }); + } + } + return values; +} + +async function readJson(filePath, fallback) { + try { + return JSON.parse(await readFile(filePath, "utf8")); + } catch (error) { + if (error.code === "ENOENT" && fallback !== undefined) return fallback; + throw error; + } +} + +async function readSamples(sessionDir) { + try { + return parseJsonLines( + await readFile(path.join(sessionDir, "samples.ndjson"), "utf8") + ); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } +} + +async function readMarkers(sessionDir) { + const markersDir = path.join(sessionDir, "markers"); + let entries; + try { + entries = await readdir(markersDir, { withFileTypes: true }); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + const markers = await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => readJson(path.join(markersDir, entry.name))) + ); + return markers.sort((left, right) => + left.capturedAt.localeCompare(right.capturedAt) + ); +} + +function linearSlope(samples) { + const usable = samples.filter( + (sample) => sample.status === "ok" && Number.isFinite(sample.totalRssBytes) + ); + if (usable.length < 2) return undefined; + const origin = Date.parse(usable[0].capturedAt); + const points = usable.map((sample) => ({ + x: (Date.parse(sample.capturedAt) - origin) / 1000, + y: sample.totalRssBytes, + })); + const meanX = points.reduce((sum, point) => sum + point.x, 0) / points.length; + const meanY = points.reduce((sum, point) => sum + point.y, 0) / points.length; + const numerator = points.reduce( + (sum, point) => sum + (point.x - meanX) * (point.y - meanY), + 0 + ); + const denominator = points.reduce( + (sum, point) => sum + (point.x - meanX) ** 2, + 0 + ); + return denominator === 0 ? 0 : numerator / denominator; +} + +export function summarizeSamples(samples) { + const usable = samples.filter( + (sample) => sample.status === "ok" && Number.isFinite(sample.totalRssBytes) + ); + const first = usable[0]; + const last = usable.at(-1); + const peak = usable.reduce( + (current, sample) => + !current || sample.totalRssBytes > current.totalRssBytes + ? sample + : current, + undefined + ); + const rolePeaks = {}; + let zombieSampleCount = 0; + for (const sample of usable) { + const roleTotals = + sample.roleRssBytes ?? + sample.processes.reduce((totals, item) => { + totals[item.role] = (totals[item.role] ?? 0) + item.rssBytes; + return totals; + }, {}); + if ( + (sample.zombieProcessCount ?? + sample.processes.filter((item) => item.state.includes("Z")).length) > 0 + ) { + zombieSampleCount += 1; + } + for (const [role, total] of Object.entries(roleTotals)) { + rolePeaks[role] = Math.max(rolePeaks[role] ?? 0, total); + } + } + const slopeBytesPerSecond = linearSlope(usable); + const deltaBytes = + first && last ? last.totalRssBytes - first.totalRssBytes : undefined; + const possibleGrowth = + usable.length >= 3 && + deltaBytes > 50 * 1024 * 1024 && + slopeBytesPerSecond > (1024 * 1024) / 60; + return { + sampleCount: samples.length, + usableSampleCount: usable.length, + firstRssBytes: first?.totalRssBytes, + lastRssBytes: last?.totalRssBytes, + deltaRssBytes: deltaBytes, + peakRssBytes: peak?.totalRssBytes, + peakCapturedAt: peak?.capturedAt, + slopeBytesPerSecond, + rolePeakRssBytes: rolePeaks, + zombieSampleCount, + verdict: possibleGrowth + ? "possible_growth" + : usable.length >= 2 + ? "no_clear_growth" + : "insufficient_data", + }; +} + +function csvCell(value) { + if (value === undefined || value === null) return ""; + const text = String(value); + return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +function buildCsv(samples) { + const headings = [ + "sequence", + "captured_at", + "sample_status", + "attribution", + "total_rss_bytes", + "pid", + "process_instance_id", + "role", + "relation", + "state", + "rss_bytes", + "virtual_bytes", + "cpu_percent", + "command", + ]; + const rows = [headings]; + for (const sample of samples) { + if (!sample.processes?.length) { + rows.push([ + sample.sequence, + sample.capturedAt, + sample.status, + sample.attribution, + sample.totalRssBytes, + ]); + continue; + } + for (const item of sample.processes) { + rows.push([ + sample.sequence, + sample.capturedAt, + sample.status, + sample.attribution, + sample.totalRssBytes, + item.pid, + item.processInstanceId, + item.role, + item.relation, + item.state, + item.rssBytes, + item.virtualBytes, + item.cpuPercent, + item.command, + ]); + } + } + return `${rows.map((row) => row.map(csvCell).join(",")).join("\n")}\n`; +} + +function formatBytes(value) { + if (!Number.isFinite(value)) return "不可用"; + const sign = value < 0 ? "-" : ""; + const absolute = Math.abs(value); + if (absolute >= 1024 ** 3) + return `${sign}${(absolute / 1024 ** 3).toFixed(2)} GiB`; + if (absolute >= 1024 ** 2) + return `${sign}${(absolute / 1024 ** 2).toFixed(1)} MiB`; + return `${sign}${(absolute / 1024).toFixed(1)} KiB`; +} + +function buildMarkdown(session, summary, markers) { + const verdict = + summary.verdict === "possible_growth" + ? "检测到持续增长迹象,需要结合阶段标记和更长时间复测。" + : summary.verdict === "no_clear_growth" + ? "本次 RSS 序列未呈现明确的持续增长迹象。" + : "有效样本不足,暂时无法判断增长趋势。"; + const markerRows = markers.length + ? markers + .map( + (marker) => + `| ${marker.capturedAt} | ${marker.label.replaceAll(/[\r\n]+/g, " ").replaceAll("|", "\\|")} |` + ) + .join("\n") + : "| — | 无 |"; + const roleRows = Object.entries(summary.rolePeakRssBytes) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([role, bytes]) => `| ${role} | ${formatBytes(bytes)} |`) + .join("\n"); + return `# ORGII 独立内存诊断报告 + +> 该报告来自外部系统采样,不会修改 App。RSS 汇总可能包含共享页,趋势用于定位,不能单独证明内存泄漏。 + +## 会话 + +| 项目 | 值 | +| --- | --- | +| 会话 ID | ${session.sessionId} | +| 根进程 | PID ${session.rootProcess.pid} | +| 开始时间 | ${session.startedAt} | +| 结束时间 | ${session.endedAt ?? "未正常结束"} | +| 停止原因 | ${session.stopReason ?? "未知"} | +| 采样间隔 | ${session.config.intervalSeconds} 秒 | +| 归因方式 | 后端进程树${session.platform === "darwin" ? " + launchctl 宿主 WebKit 服务" : ""} | + +## 结论 + +${verdict} + +- 有效样本:${summary.usableSampleCount} / ${summary.sampleCount} +- 首末变化:${formatBytes(summary.deltaRssBytes)} +- 峰值 RSS:${formatBytes(summary.peakRssBytes)} +- 线性趋势:${Number.isFinite(summary.slopeBytesPerSecond) ? `${formatBytes(summary.slopeBytesPerSecond * 60)}/分钟` : "不可用"} +- 含僵尸进程的样本数:${summary.zombieSampleCount} + +## 各角色峰值 + +| 角色 | 峰值 RSS | +| --- | ---: | +${roleRows || "| — | 不可用 |"} + +## 阶段标记 + +| 时间 | 操作 | +| --- | --- | +${markerRows} +`; +} + +export async function generateReports(sessionDir) { + const session = await readJson(path.join(sessionDir, "session.json")); + const samples = await readSamples(sessionDir); + const markers = await readMarkers(sessionDir); + const summary = summarizeSamples(samples); + const report = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + measurementNote: + "RSS sum is an external trend signal and may double-count shared pages.", + session, + summary, + markers, + samples, + }; + await writeJsonAtomic(path.join(sessionDir, "report.json"), report); + await writeFile( + path.join(sessionDir, "samples.csv"), + buildCsv(samples), + "utf8" + ); + await writeFile( + path.join(sessionDir, "summary.md"), + buildMarkdown(session, summary, markers), + "utf8" + ); + return { + sessionDir, + summary, + files: { + json: path.join(sessionDir, "report.json"), + csv: path.join(sessionDir, "samples.csv"), + markdown: path.join(sessionDir, "summary.md"), + }, + }; +} diff --git a/tools/orgii-diagnostics/lib/session-store.mjs b/tools/orgii-diagnostics/lib/session-store.mjs new file mode 100644 index 000000000..540598e44 --- /dev/null +++ b/tools/orgii-diagnostics/lib/session-store.mjs @@ -0,0 +1,237 @@ +import { randomUUID } from "node:crypto"; +import { + mkdir, + open, + readFile, + readdir, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import { collectProcessTable } from "./process-snapshot.mjs"; + +const ACTIVE_SCHEMA_VERSION = 1; + +export class ActiveSessionError extends Error { + constructor(message) { + super(message); + this.name = "ActiveSessionError"; + } +} + +export function resolveDiagnosticPaths(repoRoot, options = {}) { + const stateRoot = path.resolve( + options.stateRoot ?? path.join(repoRoot, ".orgii", "diagnostics") + ); + const outputRoot = path.resolve( + options.outputRoot ?? path.join(stateRoot, "sessions") + ); + return { + stateRoot, + outputRoot, + activePath: path.join(stateRoot, "active-session.json"), + }; +} + +export function createSessionId(now = new Date()) { + const timestamp = now.toISOString().replace(/[:.]/g, "-"); + return `${timestamp}-${randomUUID().slice(0, 8)}`; +} + +export async function writeJsonAtomic(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + await rename(temporaryPath, filePath); +} + +function assertActiveState(value) { + if ( + !value || + value.schemaVersion !== ACTIVE_SCHEMA_VERSION || + typeof value.sessionId !== "string" || + !Number.isInteger(value.recorder?.pid) || + typeof value.recorder?.startToken !== "string" || + !Number.isInteger(value.rootProcess?.pid) || + typeof value.rootProcess?.startToken !== "string" || + !path.isAbsolute(value.sessionDir) + ) { + throw new ActiveSessionError("活动会话状态已损坏,无法安全操作"); + } + return value; +} + +export async function readActiveState(activePath) { + try { + return assertActiveState(JSON.parse(await readFile(activePath, "utf8"))); + } catch (error) { + if (error.code === "ENOENT") return undefined; + if (error instanceof ActiveSessionError) throw error; + throw new ActiveSessionError(`无法读取活动会话:${error.message}`); + } +} + +export function processInstanceIsLive(rows, identity) { + return rows.some( + (row) => row.pid === identity.pid && row.startToken === identity.startToken + ); +} + +export async function inspectActiveState(activePath) { + const active = await readActiveState(activePath); + if (!active) return { state: "idle" }; + const rows = await collectProcessTable(); + const recorderLive = processInstanceIsLive(rows, active.recorder); + const rootLive = processInstanceIsLive(rows, active.rootProcess); + return { + state: recorderLive ? "recording" : "stale", + recorderLive, + rootLive, + active, + }; +} + +export async function claimActiveSession(paths, active) { + await mkdir(paths.stateRoot, { recursive: true }); + await mkdir(paths.outputRoot, { recursive: true }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await writeFile( + paths.activePath, + `${JSON.stringify(active, null, 2)}\n`, + { + encoding: "utf8", + flag: "wx", + } + ); + return; + } catch (error) { + if (error.code !== "EEXIST") throw error; + const inspection = await inspectActiveState(paths.activePath); + if (inspection.state === "recording") { + throw new ActiveSessionError( + `已有录制会话 ${inspection.active.sessionId}(记录器 PID ${inspection.active.recorder.pid})` + ); + } + const stalePath = path.join( + paths.stateRoot, + `stale-active-${Date.now()}-${randomUUID().slice(0, 8)}.json` + ); + try { + await rename(paths.activePath, stalePath); + } catch (renameError) { + if (renameError.code !== "ENOENT") throw renameError; + } + } + } + throw new ActiveSessionError("无法取得诊断录制所有权,请重试"); +} + +export async function removeActiveStateIfOwned(activePath, sessionId) { + const active = await readActiveState(activePath); + if (active?.sessionId !== sessionId) return false; + await rm(activePath, { force: true }); + return true; +} + +export async function requireLiveActiveSession(activePath) { + const inspection = await inspectActiveState(activePath); + if (inspection.state === "idle") { + throw new ActiveSessionError("当前没有正在录制的诊断会话"); + } + if (inspection.state === "stale") { + throw new ActiveSessionError( + `会话 ${inspection.active.sessionId} 的记录器已退出;请重新执行 record` + ); + } + return inspection.active; +} + +export async function addMarker(activePath, label) { + const active = await requireLiveActiveSession(activePath); + const marker = { + schemaVersion: 1, + id: randomUUID(), + sessionId: active.sessionId, + capturedAt: new Date().toISOString(), + label, + }; + const markersDir = path.join(active.sessionDir, "markers"); + await mkdir(markersDir, { recursive: true }); + await writeJsonAtomic( + path.join( + markersDir, + `${marker.capturedAt.replace(/[:.]/g, "-")}-${marker.id}.json` + ), + marker + ); + return { active, marker }; +} + +export async function requestStop(activePath) { + const active = await requireLiveActiveSession(activePath); + const request = { + schemaVersion: 1, + sessionId: active.sessionId, + requestedAt: new Date().toISOString(), + requesterPid: process.pid, + }; + await writeJsonAtomic( + path.join(active.sessionDir, "stop-request.json"), + request + ); + return { active, request }; +} + +export async function readStopRequest(sessionDir, sessionId) { + try { + const request = JSON.parse( + await readFile(path.join(sessionDir, "stop-request.json"), "utf8") + ); + return request?.sessionId === sessionId ? request : undefined; + } catch (error) { + if (error.code === "ENOENT") return undefined; + throw error; + } +} + +export async function appendJsonLine(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + const handle = await open(filePath, "a"); + try { + await handle.write(`${JSON.stringify(value)}\n`, undefined, "utf8"); + } finally { + await handle.close(); + } +} + +export async function resolveReportSessionDir(paths, requestedPath) { + if (requestedPath) { + const resolved = path.resolve(requestedPath); + const info = await stat(resolved); + if (!info.isDirectory()) throw new Error(`不是会话目录:${resolved}`); + return resolved; + } + let entries; + try { + entries = await readdir(paths.outputRoot, { withFileTypes: true }); + } catch (error) { + if (error.code === "ENOENT") throw new Error("还没有诊断会话可生成报告"); + throw error; + } + const directories = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map(async (entry) => { + const directory = path.join(paths.outputRoot, entry.name); + return { directory, modifiedAt: (await stat(directory)).mtimeMs }; + }) + ); + directories.sort((left, right) => right.modifiedAt - left.modifiedAt); + if (!directories[0]) throw new Error("还没有诊断会话可生成报告"); + return directories[0].directory; +} diff --git a/tools/orgii-diagnostics/test/args.test.mjs b/tools/orgii-diagnostics/test/args.test.mjs new file mode 100644 index 000000000..73903f55c --- /dev/null +++ b/tools/orgii-diagnostics/test/args.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CliUsageError, parseCliArgs } from "../lib/args.mjs"; + +test("record arguments have bounded, explicit defaults", () => { + assert.deepEqual(parseCliArgs(["memory", "record"]), { + command: "memory", + subcommand: "record", + pid: "auto", + intervalSeconds: 15, + maxSamples: 720, + durationSeconds: undefined, + outputRoot: undefined, + stateRoot: undefined, + }); +}); + +test("record arguments parse lifecycle bounds", () => { + assert.deepEqual( + parseCliArgs([ + "memory", + "record", + "--pid", + "42", + "--interval", + "2.5", + "--duration", + "30", + "--max-samples", + "12", + ]), + { + command: "memory", + subcommand: "record", + pid: 42, + intervalSeconds: 2.5, + maxSamples: 12, + durationSeconds: 30, + outputRoot: undefined, + stateRoot: undefined, + } + ); +}); + +test("mark keeps a human-readable label while accepting state root", () => { + assert.deepEqual( + parseCliArgs([ + "memory", + "mark", + "打开", + "20", + "个会话", + "--state-root", + "/tmp/state", + ]), + { + command: "memory", + subcommand: "mark", + label: "打开 20 个会话", + stateRoot: "/tmp/state", + } + ); +}); + +test("invalid positive values are rejected", () => { + assert.throws( + () => parseCliArgs(["memory", "record", "--max-samples", "0"]), + CliUsageError + ); +}); diff --git a/tools/orgii-diagnostics/test/lifecycle.test.mjs b/tools/orgii-diagnostics/test/lifecycle.test.mjs new file mode 100644 index 000000000..3a662f60c --- /dev/null +++ b/tools/orgii-diagnostics/test/lifecycle.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { execFile, spawn } from "node:child_process"; +import { mkdtemp, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const cliPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../cli.mjs" +); + +async function waitForFile(filePath, timeoutMs = 8_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + return JSON.parse(await readFile(filePath, "utf8")); + } catch (error) { + if (error.code !== "ENOENT" && !(error instanceof SyntaxError)) + throw error; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + throw new Error(`等待文件超时:${filePath}`); +} + +async function waitForNonEmptyFile(filePath, timeoutMs = 8_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const contents = await readFile(filePath, "utf8"); + if (contents.trim()) return contents; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`等待非空文件超时:${filePath}`); +} + +test( + "record → mark → stop finalizes one owned session and reports the marker", + { skip: process.platform === "win32", timeout: 15_000 }, + async () => { + const temporaryRoot = await mkdtemp( + path.join(os.tmpdir(), "orgii-diag-lifecycle-") + ); + const stateRoot = path.join(temporaryRoot, "state"); + const outputRoot = path.join(temporaryRoot, "output"); + const recorder = spawn( + process.execPath, + [ + cliPath, + "memory", + "record", + "--pid", + String(process.pid), + "--interval", + "60", + "--max-samples", + "10", + "--state-root", + stateRoot, + "--output", + outputRoot, + ], + { stdio: ["ignore", "pipe", "pipe"] } + ); + let stdout = ""; + let stderr = ""; + recorder.stdout.on("data", (chunk) => { + stdout += chunk; + }); + recorder.stderr.on("data", (chunk) => { + stderr += chunk; + }); + + const active = await waitForFile( + path.join(stateRoot, "active-session.json") + ); + await waitForNonEmptyFile(path.join(active.sessionDir, "samples.ndjson")); + await execFileAsync(process.execPath, [ + cliPath, + "memory", + "mark", + "完成一轮操作", + "--state-root", + stateRoot, + ]); + await execFileAsync(process.execPath, [ + cliPath, + "memory", + "stop", + "--state-root", + stateRoot, + ]); + + const exit = await new Promise((resolve, reject) => { + recorder.once("error", reject); + recorder.once("close", (code, signal) => resolve({ code, signal })); + }); + assert.deepEqual(exit, { code: 0, signal: null }, `${stdout}\n${stderr}`); + const report = JSON.parse( + await readFile(path.join(active.sessionDir, "report.json"), "utf8") + ); + assert.equal(report.session.stopReason, "external_stop"); + assert.equal(report.markers[0].label, "完成一轮操作"); + assert.ok(report.summary.usableSampleCount >= 1); + } +); diff --git a/tools/orgii-diagnostics/test/process-snapshot.test.mjs b/tools/orgii-diagnostics/test/process-snapshot.test.mjs new file mode 100644 index 000000000..82527c18e --- /dev/null +++ b/tools/orgii-diagnostics/test/process-snapshot.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + auditProcessTable, + collectOwnedProcessSnapshot, + descendantDepth, + parseLaunchctlWebKitServices, + parsePsOutput, + redactProcessCommand, + resolveRootProcess, +} from "../lib/process-snapshot.mjs"; + +const psFixture = ` + 100 1 100 501 Ss Thu Aug 13 10:00:00 2026 10240 400000000 1.5 /repo/src-tauri/target/debug/org2 + 101 100 101 501 S Thu Aug 13 10:00:01 2026 2048 200000000 0.1 /bin/zsh + 102 101 101 501 Z Thu Aug 13 10:00:02 2026 0 0 0.0 + 103 1 103 501 S Thu Aug 13 10:00:03 2026 4096 200000000 0.0 /repo/scripts/dev/stale-worker +`; + +test("ps parser preserves process identity and byte units", () => { + const rows = parsePsOutput(psFixture); + assert.equal(rows.length, 4); + assert.equal(rows[0].startToken, "Thu Aug 13 10:00:00 2026"); + assert.equal(rows[0].rssBytes, 10 * 1024 * 1024); + assert.equal(rows[2].state, "Z"); +}); + +test("root resolution and descendant traversal stay inside one process instance tree", () => { + const rows = parsePsOutput(psFixture); + const root = resolveRootProcess(rows, "auto", "/repo"); + assert.equal(root.pid, 100); + assert.equal(descendantDepth(102, 100, rows), 2); + assert.equal(descendantDepth(103, 100, rows), undefined); +}); + +test("audit distinguishes a zombie child from an adopted workspace process", () => { + const rows = parsePsOutput(psFixture); + const root = rows[0]; + const audit = auditProcessTable(rows, root, "/repo"); + assert.deepEqual( + audit.findings.map((finding) => [finding.kind, finding.pid]), + [ + ["zombie", 102], + ["adopted_workspace_process", 103], + ] + ); +}); + +test("launchctl parser accepts active WebKit roles only", () => { + const roles = parseLaunchctlWebKitServices(` + 0 - com.apple.WebKit.WebContent + 88149 - com.apple.WebKit.Networking.ABC + 88193 - com.apple.WebKit.WebContent.DEF + 88148 - com.apple.WebKit.GPU.GHI + 99999 - com.apple.SafariPlatformSupport.Helper + `); + assert.deepEqual( + [...roles.entries()], + [ + [88149, "network"], + [88193, "renderer"], + [88148, "gpu"], + ] + ); +}); + +test("snapshot refuses to continue after root PID identity changes", async () => { + const rows = parsePsOutput(psFixture); + const snapshot = await collectOwnedProcessSnapshot( + { pid: 100, startToken: "Thu Aug 13 09:59:59 2026" }, + { rows } + ); + assert.equal(snapshot.status, "root_exited"); + assert.equal(snapshot.processes.length, 0); +}); + +test("reported commands redact common secrets and URL credentials", () => { + assert.equal( + redactProcessCommand( + "agent --api-key example-api-key --token=abc https://user:password@example.com/path" + ), + "agent --api-key [REDACTED] --token=[REDACTED] https://user:[REDACTED]@example.com/path" + ); +}); diff --git a/tools/orgii-diagnostics/test/report.test.mjs b/tools/orgii-diagnostics/test/report.test.mjs new file mode 100644 index 000000000..b38ccb6c8 --- /dev/null +++ b/tools/orgii-diagnostics/test/report.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { generateReports, summarizeSamples } from "../lib/report.mjs"; + +function sample(sequence, capturedAt, totalRssBytes, role = "backend") { + return { + schemaVersion: 1, + sequence, + capturedAt, + status: "ok", + attribution: "complete", + totalRssBytes, + processes: [ + { + pid: 42, + processInstanceId: "42:start", + role, + relation: "root", + state: "S", + rssBytes: totalRssBytes, + virtualBytes: totalRssBytes * 2, + cpuPercent: 1, + command: "/tmp/org2", + }, + ], + }; +} + +test("summary flags sustained material growth without claiming a leak", () => { + const samples = [ + sample(1, "2026-08-13T00:00:00.000Z", 100 * 1024 ** 2), + sample(2, "2026-08-13T00:01:00.000Z", 140 * 1024 ** 2), + sample(3, "2026-08-13T00:02:00.000Z", 180 * 1024 ** 2), + ]; + const summary = summarizeSamples(samples); + assert.equal(summary.verdict, "possible_growth"); + assert.equal(summary.deltaRssBytes, 80 * 1024 ** 2); + assert.equal(summary.rolePeakRssBytes.backend, 180 * 1024 ** 2); +}); + +test("report writes JSON, CSV, and Markdown artifacts with markers", async () => { + const sessionDir = await mkdtemp( + path.join(os.tmpdir(), "orgii-diag-report-") + ); + const markersDir = path.join(sessionDir, "markers"); + await mkdir(markersDir); + await writeFile( + path.join(sessionDir, "session.json"), + JSON.stringify({ + schemaVersion: 1, + sessionId: "fixture", + platform: "darwin", + startedAt: "2026-08-13T00:00:00.000Z", + endedAt: "2026-08-13T00:01:00.000Z", + stopReason: "external_stop", + rootProcess: { pid: 42 }, + config: { intervalSeconds: 60 }, + }) + ); + await writeFile( + path.join(sessionDir, "samples.ndjson"), + `${JSON.stringify(sample(1, "2026-08-13T00:00:00.000Z", 100 * 1024 ** 2))}\n${JSON.stringify(sample(2, "2026-08-13T00:01:00.000Z", 110 * 1024 ** 2))}\n` + ); + await writeFile( + path.join(markersDir, "marker.json"), + JSON.stringify({ + capturedAt: "2026-08-13T00:00:30.000Z", + label: "打开 20 个会话", + }) + ); + + const result = await generateReports(sessionDir); + const report = JSON.parse(await readFile(result.files.json, "utf8")); + const markdown = await readFile(result.files.markdown, "utf8"); + const csv = await readFile(result.files.csv, "utf8"); + assert.equal(report.summary.verdict, "no_clear_growth"); + assert.equal(report.markers[0].label, "打开 20 个会话"); + assert.match(markdown, /不能单独证明内存泄漏/); + assert.match(csv, /process_instance_id/); +});