From 6927917e75a5213ce8cf9dae1961acd8c0322aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 13 Oct 2026 00:23:07 +0800 Subject: [PATCH 1/3] =?UTF-8?q?perf(transcript):=20=E6=94=B6=E6=95=9B=206?= =?UTF-8?q?=20=E4=BB=BD=E6=8C=89=20pid=20=E6=9E=9A=E4=B8=BE=E6=89=93?= =?UTF-8?q?=E5=BC=80=E6=96=87=E4=BB=B6=E7=9A=84=E5=AE=9E=E7=8E=B0=EF=BC=8C?= =?UTF-8?q?lsof=20=E7=BB=9F=E4=B8=80=E5=8A=A0=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex / coco / cursor / grok / pi / traex 六个 transcript 服务各自实现了一份 "/proc//fd 快路径 + lsof 兜底",其中 5 份用的是无 timeout 的 `execSync('lsof -p …')`:macOS 上 lsof 一旦卡住(慢挂载、对 socket 做 DNS / 端口名反查),worker / daemon 的事件循环会被同步阻塞。session-discovery 的 readCwd 同样如此。 - 新增 utils/process-open-files.ts:listProcessOpenFiles(pid, { skipDeleted }) 统一 Linux procfs 快路径与 lsof 兜底;lsof 走 execFileSync(无 shell)、 2s 超时 + SIGKILL、4MB maxBuffer(execSync 默认 1MB),并加 `-n -P` 跳过 主机名/端口名解析——只消费 `n/` 文件路径,结果不变 - 六个调用方改为"枚举 + 各自匹配器",coco / grok 保留跳过 procfs `(deleted)` 的语义;traex 原先在 /proc 缺失时直接返回 undefined,现与其余 五份一致回落 lsof(pid 已死时 lsof 同样失败,结果不变) - session-discovery readCwd 的 lsof 复用同一组有界选项 - 测试:新增 process-open-files 单测(真实打开文件可枚举、卡死的 lsof 在 上界内失败、Linux deleted 过滤),并加源码锁禁止 services/core 再出现无界 `execSync(\`lsof`(已验证在 master 上能拦下全部 6 处);traex 原有的内联 实现源码锁随之迁入。macOS 真实子进程 smoke(codex-coco-pid-discovery)通过 --- src/core/session-discovery.ts | 6 +- src/services/coco-transcript.ts | 41 +++----------- src/services/codex-transcript.ts | 54 ++---------------- src/services/cursor-transcript.ts | 39 +++---------- src/services/grok-transcript.ts | 45 ++++----------- src/services/pi-transcript.ts | 37 ++---------- src/services/traex-transcript.ts | 46 +-------------- src/utils/process-open-files.ts | 75 +++++++++++++++++++++++++ test/process-open-files.test.ts | 93 +++++++++++++++++++++++++++++++ test/traex-transcript.test.ts | 18 +----- test/write-input.test.ts | 4 +- 11 files changed, 211 insertions(+), 247 deletions(-) create mode 100644 src/utils/process-open-files.ts create mode 100644 test/process-open-files.test.ts diff --git a/src/core/session-discovery.ts b/src/core/session-discovery.ts index 6ae3b8ed27..3cc47da84c 100644 --- a/src/core/session-discovery.ts +++ b/src/core/session-discovery.ts @@ -15,6 +15,7 @@ import { findCocoSessionByPid } from '../services/coco-transcript.js'; import { findTraexRolloutByPid } from '../services/traex-transcript.js'; import { tmuxEnv } from '../setup/ensure-tmux.js'; import { herdrExecutable } from '../utils/herdr-executable.js'; +import { LSOF_EXEC_OPTIONS } from '../utils/process-open-files.js'; // macOS 没有 /proc,所以走 ps/lsof/pgrep 兜底。Linux 仍优先走 /proc 快路径。 const IS_LINUX = platform() === 'linux'; @@ -359,10 +360,7 @@ export function readCwd(pid: number): string | undefined { } } try { - const out = execSync(`lsof -a -d cwd -p ${pid} -Fn`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); + const out = execFileSync('lsof', ['-n', '-P', '-a', '-d', 'cwd', '-p', String(pid), '-Fn'], LSOF_EXEC_OPTIONS); for (const line of out.split('\n')) { if (line.startsWith('n')) return line.slice(1); } diff --git a/src/services/coco-transcript.ts b/src/services/coco-transcript.ts index 8880a5a105..986d3c90ef 100644 --- a/src/services/coco-transcript.ts +++ b/src/services/coco-transcript.ts @@ -13,17 +13,15 @@ * user messages whose `extra.is_original_user_input === true` so a Lark * turn fingerprints against the user's prompt, not injected context. */ -import { existsSync, statSync, readdirSync, readlinkSync } from 'node:fs'; -import { execSync } from 'node:child_process'; -import { platform } from 'node:os'; +import { existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { cocoCacheRoot } from './coco-paths.js'; import { scanJsonlFromOffset } from './jsonl-cursor.js'; import { logger } from '../utils/logger.js'; +import { listProcessOpenFiles } from '../utils/process-open-files.js'; const COCO_SESSIONS_ROOT = join(cocoCacheRoot(), 'sessions'); const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const IS_LINUX = platform() === 'linux'; // substring anchor —— 同时匹配 Linux 的 `/.cache/coco/sessions/` 和 macOS 的 // `/Library/Caches/coco/sessions/` 两种路径形态。anchor 后紧跟一段 UUID 才认 // 账(matchCocoSessionPath 里 SESSION_UUID_RE 校验),避免误命中。 @@ -65,36 +63,11 @@ export function cocoEventsPathForSession(sessionId: string): string { export function findCocoSessionByPid( pid: number, ): { sessionId: string; eventsPath: string } | undefined { - if (!Number.isInteger(pid) || pid <= 0) return undefined; - if (IS_LINUX) { - const fdDir = `/proc/${pid}/fd`; - if (existsSync(fdDir)) { - let entries: string[]; - try { entries = readdirSync(fdDir); } catch { return undefined; } - for (const fd of entries) { - let target: string; - try { target = readlinkSync(join(fdDir, fd)); } catch { continue; } - // procfs 标 deleted 的 fd 跳过 —— 后面 lsof 路径上没这个保护,因 - // 为 macOS lsof 不给标记;worker 端 sessionDir existsSync 兜底。 - if (target.endsWith(' (deleted)')) continue; - const hit = matchCocoSessionPath(target); - if (hit) return hit; - } - return undefined; - } - } - let out: string; - try { - out = execSync(`lsof -p ${pid} -Fn`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch { - return undefined; - } - for (const line of out.split('\n')) { - if (!line.startsWith('n/')) continue; - const target = line.slice(1); + // procfs 标 deleted 的 fd 跳过;macOS lsof 不给该标记,由 worker 端 + // sessionDir existsSync 兜底。 + const targets = listProcessOpenFiles(pid, { skipDeleted: true }); + if (!targets) return undefined; + for (const target of targets) { const hit = matchCocoSessionPath(target); if (hit) return hit; } diff --git a/src/services/codex-transcript.ts b/src/services/codex-transcript.ts index 96ab5515e6..75c8dd46a0 100644 --- a/src/services/codex-transcript.ts +++ b/src/services/codex-transcript.ts @@ -43,22 +43,18 @@ import { lstatSync, openSync, opendirSync, - readdirSync, - readlinkSync, readSync, statSync, type Dirent, } from 'node:fs'; -import { execSync } from 'node:child_process'; -import { platform } from 'node:os'; import { join } from 'node:path'; import { codexHistoryPath, codexSessionsRoot } from './codex-paths.js'; import { baselineJsonlCursor } from './jsonl-cursor.js'; import type { CodexThreadSettings } from './codex-service-tier.js'; -// cot-subject 只用语言内建,等价于内联,不违反本模块 dependency-free 口径。 +// cot-subject 与 process-open-files 只用语言/Node 内建,等价于内联,不违反本模块 dependency-free 口径。 +import { listProcessOpenFiles } from '../utils/process-open-files.js'; import { boundSubjectForTransport, subjectFromArgsString, subjectFromInputObject } from './cot-subject.js'; -const IS_LINUX = platform() === 'linux'; const UNTRUSTED_SESSION_SCAN_MAX_DEPTH = 3; const UNTRUSTED_SESSION_SCAN_MAX_ENTRIES = 50_000; @@ -74,48 +70,6 @@ export function codexSessionIdFromRolloutPath(path: string): string | undefined type CodexRolloutRef = { path: string; cliSessionId: string }; -/** Enumerate the filesystem paths a process currently has open, normalised to - * a plain string[] regardless of platform. Returns undefined only when the - * enumeration itself is unavailable (unreadable /proc, lsof failed); an empty - * array means "readable, but no matching fds". Both `findCodexRolloutByPid` - * and `findCodexRolloutSetByPid` derive from THIS single source so their - * Linux `/proc` and macOS/BSD `lsof` normalisation can never drift apart. - * - * Linux: `/proc//fd/*` fast path. - * macOS / BSD: `lsof -p -Fn` 兜底(同 session-discovery 里的 readCwd)。 */ -function codexProcessOpenTargets(pid: number): string[] | undefined { - if (!Number.isInteger(pid) || pid <= 0) return undefined; - if (IS_LINUX) { - const fdDir = `/proc/${pid}/fd`; - if (existsSync(fdDir)) { - let entries: string[]; - try { entries = readdirSync(fdDir); } catch { return undefined; } - const targets: string[] = []; - for (const fd of entries) { - let target: string; - try { target = readlinkSync(join(fdDir, fd)); } catch { continue; } - targets.push(target); - } - return targets; - } - // /proc 不可读时落到下面的 lsof 兜底(极少见,但兜一下) - } - // BSD ps 的 lsof:每个 fd 输出一行 `f` 加一行 `n`,socket / pipe - // 之类的内部条目以 `n->0x...` 或 `n` 开头,所以只接受 `n/` 开头。 - let out: string; - try { - out = execSync(`lsof -p ${pid} -Fn`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch { - return undefined; - } - return out.split('\n').flatMap(line => - line.startsWith('n/') ? [line.slice(1)] : [], - ); -} - /** Find the rollout file an externally-running Codex process has open. A * single open rollout is authoritative for that pid. Multiple open rollouts * are ambiguous: current Codex versions can keep parent and sibling-agent @@ -124,7 +78,7 @@ function codexProcessOpenTargets(pid: number): string[] | undefined { * * 两种平台都用 `codexSessionIdFromRolloutPath` 提取 sid。 */ export function findCodexRolloutByPid(pid: number): CodexRolloutRef | undefined { - const targets = codexProcessOpenTargets(pid); + const targets = listProcessOpenFiles(pid); if (!targets) return undefined; return findSingleCodexRollout(targets); } @@ -143,7 +97,7 @@ export function findCodexRolloutByPid(pid: number): CodexRolloutRef | undefined * undefined only when the fd enumeration itself is unavailable — callers must * treat undefined as "cannot prove ownership" (fail closed: do not re-attach). */ export function findCodexRolloutSetByPid(pid: number): Set | undefined { - const targets = codexProcessOpenTargets(pid); + const targets = listProcessOpenFiles(pid); if (!targets) return undefined; const set = new Set(); for (const target of targets) { diff --git a/src/services/cursor-transcript.ts b/src/services/cursor-transcript.ts index 6eeb77abe4..e22597558a 100644 --- a/src/services/cursor-transcript.ts +++ b/src/services/cursor-transcript.ts @@ -50,13 +50,12 @@ * * Pure I/O. Attribution belongs in CodexBridgeQueue. */ -import { existsSync, statSync, openSync, readSync, closeSync, readdirSync, readlinkSync } from 'node:fs'; -import { execSync } from 'node:child_process'; -import { homedir, platform } from 'node:os'; +import { existsSync, statSync, openSync, readSync, closeSync, readdirSync } from 'node:fs'; +import { homedir } from 'node:os'; import { join } from 'node:path'; import type { CodexBridgeEvent } from './codex-transcript.js'; +import { listProcessOpenFiles } from '../utils/process-open-files.js'; -const IS_LINUX = platform() === 'linux'; const CHAT_ID_RE = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; @@ -87,34 +86,10 @@ export function cursorChatIdFromStoreDbPath(path: string): string | undefined { * Linux: `/proc//fd/*` fast path. macOS / BSD: `lsof -p -Fn` * fallback (same shape as codex-transcript.findCodexRolloutByPid). */ export function findCursorChatIdByPid(pid: number): string | undefined { - if (!Number.isInteger(pid) || pid <= 0) return undefined; - if (IS_LINUX) { - const fdDir = `/proc/${pid}/fd`; - if (existsSync(fdDir)) { - let entries: string[]; - try { entries = readdirSync(fdDir); } catch { return undefined; } - for (const fd of entries) { - let target: string; - try { target = readlinkSync(join(fdDir, fd)); } catch { continue; } - const chatId = cursorChatIdFromStoreDbPath(target); - if (chatId) return chatId; - } - return undefined; - } - // /proc unreadable — fall through to lsof. - } - let out: string; - try { - out = execSync(`lsof -p ${pid} -Fn`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch { - return undefined; - } - for (const line of out.split('\n')) { - if (!line.startsWith('n/')) continue; - const chatId = cursorChatIdFromStoreDbPath(line.slice(1)); + const targets = listProcessOpenFiles(pid); + if (!targets) return undefined; + for (const target of targets) { + const chatId = cursorChatIdFromStoreDbPath(target); if (chatId) return chatId; } return undefined; diff --git a/src/services/grok-transcript.ts b/src/services/grok-transcript.ts index 977db2047d..382c749267 100644 --- a/src/services/grok-transcript.ts +++ b/src/services/grok-transcript.ts @@ -27,10 +27,8 @@ * HOL-block-drop still applies if a steer-like merge ever appears. */ import { - existsSync, statSync, openSync, readSync, closeSync, readdirSync, readFileSync, readlinkSync, + existsSync, statSync, openSync, readSync, closeSync, readdirSync, readFileSync, } from 'node:fs'; -import { execSync } from 'node:child_process'; -import { platform } from 'node:os'; import { dirname, join } from 'node:path'; import type { CodexBridgeEvent, CodexDrainResult } from './codex-transcript.js'; import { @@ -38,9 +36,9 @@ import { resolveGrokCwdBucketDir, } from './grok-paths.js'; import { isBotmuxInjectedPrompt } from './resumable-session-discovery.js'; +import { listProcessOpenFiles } from '../utils/process-open-files.js'; import type { ResumableSession } from '../adapters/cli/types.js'; -const IS_LINUX = platform() === 'linux'; const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const GROK_SESSIONS_ANCHOR = '/.grok/sessions/'; const TITLE_MAX = 80; @@ -182,37 +180,14 @@ export function findGrokSessionByPid( } return best?.hit; }; - if (IS_LINUX) { - const fdDir = `/proc/${pid}/fd`; - if (existsSync(fdDir)) { - let entries: string[]; - try { entries = readdirSync(fdDir); } catch { return undefined; } - for (const fd of entries) { - let target: string; - try { target = readlinkSync(join(fdDir, fd)); } catch { continue; } - if (target.endsWith(' (deleted)')) continue; - const hit = matchGrokSessionPath(target); - if (hit && !hits.some((seen) => seen.sessionId === hit.sessionId)) hits.push(hit); - } - // During `/new` Grok can briefly retain descriptors for both session - // directories. Prefer the stream most recently appended instead of - // depending on /proc fd enumeration order (which could rotate the - // worker bridge backwards to the retired session). - return newestHit(); - } - } - let out: string; - try { - out = execSync(`lsof -p ${pid} -Fn`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch { - return undefined; - } - for (const line of out.split('\n')) { - if (!line.startsWith('n/')) continue; - const hit = matchGrokSessionPath(line.slice(1)); + // During `/new` Grok can briefly retain descriptors for both session + // directories. Prefer the stream most recently appended instead of + // depending on fd enumeration order (which could rotate the worker bridge + // backwards to the retired session). + const targets = listProcessOpenFiles(pid, { skipDeleted: true }); + if (!targets) return undefined; + for (const target of targets) { + const hit = matchGrokSessionPath(target); if (hit && !hits.some((seen) => seen.sessionId === hit.sessionId)) hits.push(hit); } return newestHit(); diff --git a/src/services/pi-transcript.ts b/src/services/pi-transcript.ts index dd966b80da..3592ef6aa8 100644 --- a/src/services/pi-transcript.ts +++ b/src/services/pi-transcript.ts @@ -66,12 +66,12 @@ * matching turn — identical to Codex/Grok. Non-steered turns stay strictly * interleaved (user → assistant_final → user → assistant_final). */ -import { existsSync, statSync, openSync, readSync, closeSync, readdirSync, readlinkSync } from 'node:fs'; -import { execSync } from 'node:child_process'; -import { homedir, platform } from 'node:os'; +import { existsSync, statSync, openSync, readSync, closeSync, readdirSync } from 'node:fs'; +import { homedir } from 'node:os'; import { join } from 'node:path'; import { codexTaskFailureCode, safeFailureSummary, type CodexCotEntry } from './codex-transcript.js'; import { boundSubjectForTransport, subjectFromInputObject } from './cot-subject.js'; +import { listProcessOpenFiles } from '../utils/process-open-files.js'; import { PI_TURN_BOUNDARY_CUSTOM_TYPE, PI_TURN_BOUNDARY_STOP_REASON_ERROR, @@ -79,7 +79,6 @@ import { const PI_SESSIONS_ROOT = join(homedir(), '.pi', 'agent', 'sessions'); const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const IS_LINUX = platform() === 'linux'; export interface PiBridgeEvent { uuid: string; @@ -262,33 +261,9 @@ export function findPiTranscriptBySessionId(cliSessionId: string, cwd?: string): } export function findPiTranscriptByPid(pid: number): { path: string; cliSessionId: string } | undefined { - if (!Number.isInteger(pid) || pid <= 0) return undefined; - if (IS_LINUX) { - const fdDir = `/proc/${pid}/fd`; - if (existsSync(fdDir)) { - let entries: string[]; - try { entries = readdirSync(fdDir); } catch { return undefined; } - for (const fd of entries) { - let target: string; - try { target = readlinkSync(join(fdDir, fd)); } catch { continue; } - const hit = matchPiTranscriptPath(target); - if (hit) return hit; - } - return undefined; - } - } - let out: string; - try { - out = execSync(`lsof -p ${pid} -Fn`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch { - return undefined; - } - for (const line of out.split('\n')) { - if (!line.startsWith('n/')) continue; - const target = line.slice(1); + const targets = listProcessOpenFiles(pid); + if (!targets) return undefined; + for (const target of targets) { const hit = matchPiTranscriptPath(target); if (hit) return hit; } diff --git a/src/services/traex-transcript.ts b/src/services/traex-transcript.ts index f9f63cfcfd..5169985473 100644 --- a/src/services/traex-transcript.ts +++ b/src/services/traex-transcript.ts @@ -41,12 +41,9 @@ import { openSync, readSync, readdirSync, - readlinkSync, statSync, type Dirent, } from 'node:fs'; -import { execFileSync } from 'node:child_process'; -import { platform } from 'node:os'; import { isAbsolute, join, relative, sep } from 'node:path'; import { splitCodexEventsByCutoff, @@ -68,6 +65,7 @@ import { isInternalCodexSessionMeta } from './codex-session-meta.js'; import { openDatabaseSyncNow } from './sqlite-compat.js'; import { baselineJsonlCursor } from './jsonl-cursor.js'; import { traeSessionsRoot, traeStateDbPath } from './traex-paths.js'; +import { listProcessOpenFiles } from '../utils/process-open-files.js'; export { splitCodexEventsByCutoff as splitTraexEventsByCutoff }; export { extractLastCodexTurn as extractLastTraexTurn }; @@ -99,12 +97,10 @@ export interface TraexRuntimeSnapshot { reasoningEffort?: string; } -const IS_LINUX = platform() === 'linux'; const TRAEX_SESSION_META_SCAN_MAX_BYTES = 4 * 1024 * 1024; const TRAEX_ROLLOUT_LOOKUP_INITIAL_BACKOFF_MS = 2_000; const TRAEX_ROLLOUT_LOOKUP_MAX_BACKOFF_MS = 8_000; const TRAEX_ROLLOUT_LOOKUP_MISS_CACHE_MAX = 256; -const TRAEX_LSOF_TIMEOUT_MS = 1_000; const TRAEX_YEAR_DIR_RE = /^\d{4}$/; const TRAEX_MONTH_DIR_RE = /^(?:0[1-9]|1[0-2])$/; const TRAEX_DAY_DIR_RE = /^(?:0[1-9]|[12]\d|3[01])$/; @@ -1349,42 +1345,6 @@ function selectTraexRollout( return newest; } -/** Enumerate the file paths a pid holds open (Linux /proc, else lsof). Shared - * by findTraexRolloutByPid (single) and findTraexRolloutSetByPid (ownership - * set) so both derive from one source. Returns undefined when enumeration is - * unavailable — callers treat that as "cannot prove ownership". */ -function traexProcessOpenTargets(pid: number): string[] | undefined { - if (!Number.isInteger(pid) || pid <= 0) return undefined; - if (IS_LINUX) { - const fdDir = `/proc/${pid}/fd`; - if (!existsSync(fdDir)) return undefined; - let entries: string[]; - try { entries = readdirSync(fdDir); } catch { return undefined; } - const targets: string[] = []; - for (const fd of entries) { - try { targets.push(readlinkSync(join(fdDir, fd))); } catch { continue; } - } - return targets; - } - let out: string; - try { - out = execFileSync('lsof', ['-p', String(pid), '-Fn'], { - encoding: 'utf-8', - timeout: TRAEX_LSOF_TIMEOUT_MS, - killSignal: 'SIGKILL', - maxBuffer: 4 * 1024 * 1024, - stdio: ['ignore', 'pipe', 'ignore'], - }); - } catch { - return undefined; - } - const targets: string[] = []; - for (const line of out.split('\n')) { - if (line.startsWith('n/')) targets.push(line.slice(1)); - } - return targets; -} - /** Find the visible top-level rollout an externally-running TRAE process owns. * TRAE can keep the parent rollout and internal guardian/subagent rollouts open * in the same process. Internal rollouts are never adoptable. When a current @@ -1394,7 +1354,7 @@ export function findTraexRolloutByPid( pid: number, preferredSessionId?: string, ): { path: string; cliSessionId: string } | undefined { - const targets = traexProcessOpenTargets(pid); + const targets = listProcessOpenFiles(pid); if (!targets) return undefined; const selected = selectTraexRollout(traexRolloutRefs(targets), preferredSessionId); return selected ? { path: selected.path, cliSessionId: selected.cliSessionId } : undefined; @@ -1408,7 +1368,7 @@ export function findTraexRolloutByPid( * as "cannot prove ownership" — fail closed, do not bind). Mirrors * findCodexRolloutSetByPid. */ export function findTraexRolloutSetByPid(pid: number): Set | undefined { - const targets = traexProcessOpenTargets(pid); + const targets = listProcessOpenFiles(pid); if (!targets) return undefined; const set = new Set(); for (const ref of selectableTraexRollouts(traexRolloutRefs(targets))) { diff --git a/src/utils/process-open-files.ts b/src/utils/process-open-files.ts new file mode 100644 index 0000000000..846db3dca4 --- /dev/null +++ b/src/utils/process-open-files.ts @@ -0,0 +1,75 @@ +import { execFileSync, type ExecFileSyncOptionsWithStringEncoding } from 'node:child_process'; +import { existsSync, readdirSync, readlinkSync } from 'node:fs'; +import { platform } from 'node:os'; +import { join } from 'node:path'; + +const IS_LINUX = platform() === 'linux'; + +/** Upper bound for one `lsof` probe. These lookups run synchronously on worker + * and daemon paths, so an lsof stalled on a slow mount must not freeze the + * event loop; a timed-out probe reads as "enumeration unavailable". */ +export const LSOF_TIMEOUT_MS = 2_000; + +/** Shared bounded options for synchronous `lsof` probes. `-n -P` belong in the + * argv (callers only consume file paths), this covers the process limits. */ +export const LSOF_EXEC_OPTIONS: ExecFileSyncOptionsWithStringEncoding = { + encoding: 'utf-8', + timeout: LSOF_TIMEOUT_MS, + killSignal: 'SIGKILL', + maxBuffer: 4 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'ignore'], +}; + +export interface ProcessOpenFilesOptions { + /** Drop procfs targets marked ` (deleted)` (unlinked but still open). lsof + * output carries no such marker, so this only affects the Linux path. */ + skipDeleted?: boolean; +} + +/** + * Enumerate the filesystem paths a process currently holds open. + * + * Linux: `/proc//fd` fast path; falls back to lsof when procfs is not + * readable for the pid. macOS / BSD: `lsof -n -P -p -Fn`, keeping only + * `n/` lines (sockets / pipes render as `n->0x…` or other non-path names). + * `-n -P` skip host / port name resolution, which is what makes a plain + * `lsof -p` slow or hang on a process with network sockets. + * + * Returns undefined only when enumeration itself is unavailable (unreadable + * procfs, lsof missing / failed / timed out); an empty array means "readable, + * but nothing open". + */ +export function listProcessOpenFiles( + pid: number, + opts: ProcessOpenFilesOptions = {}, +): string[] | undefined { + if (!Number.isInteger(pid) || pid <= 0) return undefined; + if (IS_LINUX) { + const fdDir = `/proc/${pid}/fd`; + if (existsSync(fdDir)) { + let entries: string[]; + try { entries = readdirSync(fdDir); } catch { return undefined; } + const targets: string[] = []; + for (const fd of entries) { + let target: string; + try { target = readlinkSync(join(fdDir, fd)); } catch { continue; } + if (opts.skipDeleted && target.endsWith(' (deleted)')) continue; + targets.push(target); + } + return targets; + } + // procfs unreadable for this pid — fall through to lsof. + } + let out: string; + try { + out = execFileSync('lsof', ['-n', '-P', '-p', String(pid), '-Fn'], LSOF_EXEC_OPTIONS); + } catch { + return undefined; + } + if (typeof out !== 'string') return undefined; + const targets: string[] = []; + for (const line of out.split('\n')) { + if (line.startsWith('n/')) targets.push(line.slice(1)); + } + return targets; +} diff --git a/test/process-open-files.test.ts b/test/process-open-files.test.ts new file mode 100644 index 0000000000..e3df6acafa --- /dev/null +++ b/test/process-open-files.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { closeSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { listProcessOpenFiles } from '../src/utils/process-open-files.js'; + +let dir: string; + +beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'botmux-open-files-'))); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('listProcessOpenFiles', () => { + it('rejects non-positive / non-integer pids without probing', () => { + expect(listProcessOpenFiles(0)).toBeUndefined(); + expect(listProcessOpenFiles(-1)).toBeUndefined(); + expect(listProcessOpenFiles(1.5)).toBeUndefined(); + }); + + it('lists a file the process currently holds open', () => { + const file = join(dir, 'held.jsonl'); + writeFileSync(file, ''); + const fd = openSync(file, 'r'); + try { + const targets = listProcessOpenFiles(process.pid); + expect(targets).toBeDefined(); + expect(targets).toContain(file); + } finally { + closeSync(fd); + } + }); + + it.skipIf(process.platform !== 'linux')('skipDeleted drops procfs targets of unlinked files', () => { + const file = join(dir, 'unlinked.jsonl'); + writeFileSync(file, ''); + const fd = openSync(file, 'r'); + try { + unlinkSync(file); + expect(listProcessOpenFiles(process.pid)).toContain(`${file} (deleted)`); + expect(listProcessOpenFiles(process.pid, { skipDeleted: true })).not.toContain(`${file} (deleted)`); + } finally { + closeSync(fd); + } + }); + + it.skipIf(process.platform === 'linux')('fails soft within the bound when lsof stalls', () => { + const fakeLsof = join(dir, 'lsof'); + writeFileSync(fakeLsof, '#!/bin/sh\nsleep 10\n', { mode: 0o755 }); + const originalPath = process.env.PATH; + process.env.PATH = `${dir}:${originalPath ?? ''}`; + const startedAt = Date.now(); + try { + expect(listProcessOpenFiles(process.pid)).toBeUndefined(); + } finally { + process.env.PATH = originalPath; + } + expect(Date.now() - startedAt).toBeLessThan(5_000); + }); +}); + +/** + * Source lock: pid → open-file probes must go through the bounded helper. + * Before it existed, five transcript services each carried their own + * `execSync('lsof -p …')` with no timeout, so a stalled lsof froze the worker + * event loop. Pin both the helper's bounds and the absence of raw copies. + */ +describe('lsof probes stay bounded', () => { + const helperSource = readFileSync(new URL('../src/utils/process-open-files.ts', import.meta.url), 'utf8'); + + it('the helper runs lsof shell-free with a hard timeout and no name resolution', () => { + expect(helperSource).toContain("execFileSync('lsof', ['-n', '-P', '-p', String(pid), '-Fn'], LSOF_EXEC_OPTIONS)"); + expect(helperSource).toMatch(/timeout: LSOF_TIMEOUT_MS/); + expect(helperSource).toMatch(/killSignal: 'SIGKILL'/); + expect(helperSource).not.toContain('execSync('); + }); + + it('no service or core module spawns an unbounded lsof', () => { + const offenders: string[] = []; + for (const sub of ['services', 'core']) { + const root = new URL(`../src/${sub}/`, import.meta.url); + for (const name of readdirSync(root)) { + if (!name.endsWith('.ts')) continue; + const source = readFileSync(new URL(name, root), 'utf8'); + if (/execSync\(\s*`lsof/.test(source)) offenders.push(`${sub}/${name}`); + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/test/traex-transcript.test.ts b/test/traex-transcript.test.ts index 0cba42d0cf..3003575ede 100644 --- a/test/traex-transcript.test.ts +++ b/test/traex-transcript.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { CodexBridgeQueue } from '../src/services/codex-bridge-queue.js'; @@ -23,10 +23,6 @@ import { import { openDatabaseSyncNow } from '../src/services/sqlite-compat.js'; const SID = '00000000-0000-7000-8000-000000000001'; -const traexTranscriptSource = readFileSync( - new URL('../src/services/traex-transcript.ts', import.meta.url), - 'utf8', -); let dir: string; let path: string; @@ -2674,18 +2670,6 @@ describe('traexHistorySidIsOwned (ownership gate predicate)', () => { }); describe('TRAE pid ownership probe on macOS/BSD', () => { - it('uses a shell-free lsof call with a hard timeout', () => { - const start = traexTranscriptSource.indexOf('function traexProcessOpenTargets'); - const end = traexTranscriptSource.indexOf('/** Find the visible top-level rollout', start); - const body = traexTranscriptSource.slice(start, end); - - expect(body).toContain("execFileSync('lsof', ['-p', String(pid), '-Fn']"); - expect(body).toContain('timeout: TRAEX_LSOF_TIMEOUT_MS'); - expect(body).toContain("killSignal: 'SIGKILL'"); - expect(body).not.toContain('execSync('); - expect(traexTranscriptSource).toContain('const TRAEX_LSOF_TIMEOUT_MS = 1_000;'); - }); - it.skipIf(process.platform !== 'darwin')('kills a stalled lsof probe within the bounded deadline', () => { const fakeLsof = join(dir, 'lsof'); writeFileSync(fakeLsof, '#!/bin/sh\nsleep 10\n'); diff --git a/test/write-input.test.ts b/test/write-input.test.ts index 2867b578bd..43046c1a60 100644 --- a/test/write-input.test.ts +++ b/test/write-input.test.ts @@ -39,7 +39,9 @@ vi.mock('node:child_process', () => { return { ...actual, execSync: vi.fn(() => ''), - execFileSync: vi.fn(), + // Bounded pid → open-file probes (utils/process-open-files) run lsof via + // execFileSync; mirror execSync's "ran fine, nothing open" stub. + execFileSync: vi.fn(() => ''), // writeInput's coco/codex paths call execFile. Spreading the real builtin // without stubbing it would spawn live CLIs and hang the file (measured). execFile: vi.fn((...args: unknown[]) => { From cca4dee6ffc509f320e603cab570429ef79651b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 13 Oct 2026 00:26:33 +0800 Subject: [PATCH 2/3] =?UTF-8?q?perf(lark):=20=E7=83=AD=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=20bots-info.json=20=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=8C=89=E6=96=87=E4=BB=B6=E6=8C=87=E7=BA=B9?= =?UTF-8?q?=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bots-info.json 由每个 bot daemon 合并写入,内容只在 daemon 启动/改名时变化, 但以下热路径每次都 existsSync + readFileSync + JSON.parse 整个文件: - worker-pool loadKnownBotOpenIdsForApp:每个 turn 的 final output 路径上 调用 2–3 次(页脚收件人、失败兜底 @、零注入发起人) - event-dispatcher 的兄弟 bot 名解析与 updateBotOpenIdCrossRef:每条带 @ 的入站消息 新增 services/bots-info-cache.ts,沿用 peer-cross-ref-store 的指纹方案 (dev:ino:size:mtime:ctime):每次仍 stat,只有兄弟 daemon 原子替换后才重读 重解析。语义与原先 existsSync + JSON.parse 逐项等价:stat 失败 → undefined; 损坏 → 每次抛同一个解析错误(按指纹缓存),调用方原有 catch 分支行为不变。 写路径(writeBotInfoFile 的读-改-写)与 fail-closed 读取方保持直读。 --- src/core/worker-pool.ts | 11 ++--- src/im/lark/event-dispatcher.ts | 12 ++--- src/services/bots-info-cache.ts | 86 +++++++++++++++++++++++++++++++++ test/bots-info-cache.test.ts | 61 +++++++++++++++++++++++ 4 files changed, 157 insertions(+), 13 deletions(-) create mode 100644 src/services/bots-info-cache.ts create mode 100644 test/bots-info-cache.test.ts diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 96705d01af..ea794e426b 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -675,6 +675,7 @@ import { isStructuredBridgeAdoptCli } from '../services/structured-bridge-clis.j import { resolveEffectivePluginIds } from './plugins/effective.js'; import { ensureGatewayEntry } from './plugins/mcp/gateway-installer.js'; import { readPeerCrossRef } from '../services/peer-cross-ref-store.js'; +import { readBotsInfoJson } from '../services/bots-info-cache.js'; import { claimQuotaFallbackEvent, resolveQuotaFallbackTarget, @@ -2734,12 +2735,10 @@ function loadKnownBotOpenIdsForApp(larkAppId: string): Set { const dataDir = config.session.dataDir; const crossRef = readPeerCrossRef(dataDir, larkAppId); - let botEntries: BotMentionEntry[] = []; - const botInfoPath = join(dataDir, 'bots-info.json'); - if (existsSync(botInfoPath)) { - const parsed = JSON.parse(readFileSync(botInfoPath, 'utf-8')); - if (Array.isArray(parsed)) botEntries = parsed as BotMentionEntry[]; - } + // Runs on every turn's final output; the cached read only re-parses when + // a sibling daemon actually rewrote bots-info.json. + const parsed = readBotsInfoJson(dataDir); + const botEntries: BotMentionEntry[] = Array.isArray(parsed) ? parsed as BotMentionEntry[] : []; return knownBotOpenIdsFromCrossRef(crossRef, botEntries, larkAppId); } diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index 73de0723f3..7586a09898 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -86,6 +86,7 @@ import type { VcMeetingPushContext, VcMeetingPushEventKind } from '../../vc-agen import type { Session, VcMeetingImTurnOrigin } from '../../types.js'; import { DEFAULT_GRANT_DURATION_MS, DEFAULT_GRANT_QUOTA } from '../../services/grant-policy.js'; import { readPeerCrossRef, writePeerCrossRef } from '../../services/peer-cross-ref-store.js'; +import { readBotsInfoJson } from '../../services/bots-info-cache.js'; import { resolveCardActionAckTimeoutMs } from '../../core/card-action-ack.js'; import { DROPPED_REACTION_EMOJI_TYPE } from '../../core/pending-response.js'; import type { DurableInboxStore } from '../../services/durable-coordination.js'; @@ -1435,10 +1436,8 @@ export function resolveSiblingBotNameByUnionId( // appId → current botName from the shared, rename-fresh bots-info.json. try { - const infoPath = join(dataDir, 'bots-info.json'); - if (existsSync(infoPath)) { - const entries: Array<{ larkAppId?: string; botName?: string | null }> = - JSON.parse(readFileSync(infoPath, 'utf-8')); + const entries = readBotsInfoJson(dataDir) as Array<{ larkAppId?: string; botName?: string | null }> | undefined; + if (entries) { const name = entries.find(e => e.larkAppId === siblingAppId)?.botName; if (typeof name === 'string' && name.trim()) return name.trim(); } @@ -1493,9 +1492,8 @@ export function updateBotOpenIdCrossRef( // Read known bot names from bots-info.json const knownBotNames = new Set(); try { - const infoPath = join(dataDir, 'bots-info.json'); - if (existsSync(infoPath)) { - const entries: Array<{ botName: string | null }> = JSON.parse(readFileSync(infoPath, 'utf-8')); + const entries = readBotsInfoJson(dataDir) as Array<{ botName: string | null }> | undefined; + if (entries) { for (const e of entries) { if (e.botName) knownBotNames.add(e.botName.toLowerCase()); } diff --git a/src/services/bots-info-cache.ts b/src/services/bots-info-cache.ts new file mode 100644 index 0000000000..c80d6e01f8 --- /dev/null +++ b/src/services/bots-info-cache.ts @@ -0,0 +1,86 @@ +/** + * Process-local read cache for the shared `bots-info.json` (one entry per bot, + * merged in by every bot daemon via writeBotInfoFile). + * + * Same fingerprint scheme as peer-cross-ref-store: every read still stats the + * file, but its bytes are only re-read and re-parsed when the fingerprint + * changes, so a sibling daemon's atomic replace is visible on the next call. + * Meant for hot paths (every inbound @-mention, every turn's final output); + * writers must keep reading the file directly for their read-modify-write. + * + * Semantics deliberately match a bare `existsSync` + `JSON.parse`: + * - missing / unstat-able file → undefined (like `existsSync` → false) + * - parseable → the parsed value (shared across calls — treat as read-only) + * - corrupt → throws the parse error (cached for that exact fingerprint), so + * callers' existing catch branches behave exactly as before + */ +import { readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +type CacheEntry = + | { version: string; ok: true; value: unknown } + | { version: string; ok: false; error: unknown }; + +const cache = new Map(); + +function fileVersion(fp: string): string { + try { + const stat = statSync(fp); + // Writers publish through atomic rename. Include inode and both timestamps + // so a same-size replacement is not hidden by coarse mtime resolution. + return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`; + } catch (err: any) { + if (err?.code === 'ENOENT') return 'missing'; + return `unavailable:${err?.code ?? 'unknown'}`; + } +} + +function settle(entry: CacheEntry): unknown { + if (entry.ok) return entry.value; + throw entry.error; +} + +/** Parsed contents of `/bots-info.json`; see module doc for semantics. */ +export function readBotsInfoJson(dataDir: string): unknown { + const fp = join(dataDir, 'bots-info.json'); + + // Retry a concurrently replaced file instead of associating bytes from one + // inode with another inode's fingerprint. Atomic writers make this converge. + for (let attempt = 0; attempt < 3; attempt++) { + const before = fileVersion(fp); + const cached = cache.get(fp); + if (cached?.version === before) return settle(cached); + // existsSync() is false for any stat failure, not just ENOENT — match it. + if (before === 'missing' || before.startsWith('unavailable:')) { + cache.set(fp, { version: before, ok: true, value: undefined }); + return undefined; + } + + let raw: string; + try { + raw = readFileSync(fp, 'utf-8'); + } catch (error) { + if (fileVersion(fp) !== before) continue; + throw error; + } + const after = fileVersion(fp); + if (after !== before) continue; + + let entry: CacheEntry; + try { + entry = { version: after, ok: true, value: JSON.parse(raw) }; + } catch (error) { + entry = { version: after, ok: false, error }; + } + cache.set(fp, entry); + return settle(entry); + } + + // The file kept changing under us; fall back to one uncached read. + return JSON.parse(readFileSync(fp, 'utf-8')); +} + +/** Test-only: isolate process-local cache state between cases. */ +export function __resetBotsInfoCacheForTest(): void { + cache.clear(); +} diff --git a/test/bots-info-cache.test.ts b/test/bots-info-cache.test.ts new file mode 100644 index 0000000000..d0ec582973 --- /dev/null +++ b/test/bots-info-cache.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { __resetBotsInfoCacheForTest, readBotsInfoJson } from '../src/services/bots-info-cache.js'; + +let dir: string; + +function publish(value: unknown, raw?: string): void { + // Mirror sibling daemons: write a temp file, then atomically rename it in. + const tmp = join(dir, `bots-info.json.${Math.random().toString(16).slice(2)}.tmp`); + writeFileSync(tmp, raw ?? JSON.stringify(value)); + renameSync(tmp, join(dir, 'bots-info.json')); +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'botmux-bots-info-')); + __resetBotsInfoCacheForTest(); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('readBotsInfoJson', () => { + it('returns undefined when the file does not exist', () => { + expect(readBotsInfoJson(dir)).toBeUndefined(); + }); + + it('parses once and serves the same snapshot while the file is unchanged', () => { + publish([{ larkAppId: 'cli_a', botName: 'A' }]); + const first = readBotsInfoJson(dir); + expect(first).toEqual([{ larkAppId: 'cli_a', botName: 'A' }]); + expect(readBotsInfoJson(dir)).toBe(first); + }); + + it('sees a sibling daemon atomically replacing the file', () => { + publish([{ larkAppId: 'cli_a', botName: 'A' }]); + expect(readBotsInfoJson(dir)).toEqual([{ larkAppId: 'cli_a', botName: 'A' }]); + publish([{ larkAppId: 'cli_a', botName: 'A' }, { larkAppId: 'cli_b', botName: 'B' }]); + expect(readBotsInfoJson(dir)).toEqual([ + { larkAppId: 'cli_a', botName: 'A' }, + { larkAppId: 'cli_b', botName: 'B' }, + ]); + }); + + it('sees the file disappearing', () => { + publish([]); + expect(readBotsInfoJson(dir)).toEqual([]); + rmSync(join(dir, 'bots-info.json')); + expect(readBotsInfoJson(dir)).toBeUndefined(); + }); + + it('throws on a corrupt file every time, and recovers once it is repaired', () => { + publish(undefined, '{not json'); + expect(() => readBotsInfoJson(dir)).toThrow(SyntaxError); + expect(() => readBotsInfoJson(dir)).toThrow(SyntaxError); + publish([{ larkAppId: 'cli_a' }]); + expect(readBotsInfoJson(dir)).toEqual([{ larkAppId: 'cli_a' }]); + }); +}); From 9862a3e2633f0de1999f7a1fd77edd6879e988a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 13 Oct 2026 00:28:32 +0800 Subject: [PATCH 3/3] =?UTF-8?q?perf(dashboard):=20=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E8=A1=A8=E5=8D=95=E6=8E=A7=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?bot-defaults=20=E9=A1=B5=E5=9B=9E=E5=88=B0=E6=87=92=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bot-onboarding.tsx(被 app.tsx 静态引入)与 group-default-models.tsx 为了 ModelPickerField / DropdownField 直接 import 8k 行的 bot-defaults-page.tsx, 使这个本应按路由懒加载的整页被打进首屏静态依赖。 - 两个控件(及 DropdownFieldOption 类型)原样移到新的 field-controls.tsx, 三个使用方改从这里引入;dashboard-routes 的懒加载入口成为 bot-defaults-page 唯一的引用方 - 测试:model-picker 源码锁改为断言 onboarding 从 field-controls 引入且不再 静态依赖 bot-defaults-page;group-default-models 测试改 import 路径 效果(esbuild splitting + minify,统计 app.js 静态可达的全部 chunk): 首屏 JS 1117KB → 888KB(gzip 300KB → 249KB),bot-defaults-page 不再在首屏。 --- src/dashboard/web/bot-defaults-page.tsx | 149 +---------------- src/dashboard/web/bot-onboarding.tsx | 2 +- src/dashboard/web/field-controls.tsx | 155 ++++++++++++++++++ src/dashboard/web/group-default-models.tsx | 2 +- ...ashboard-bot-defaults-model-picker.test.ts | 12 +- test/dashboard-group-default-models.test.ts | 2 +- 6 files changed, 167 insertions(+), 155 deletions(-) create mode 100644 src/dashboard/web/field-controls.tsx diff --git a/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx index 7a2574a179..19a8f6c08b 100644 --- a/src/dashboard/web/bot-defaults-page.tsx +++ b/src/dashboard/web/bot-defaults-page.tsx @@ -43,6 +43,7 @@ import { import { isRemoteCliId } from '../../core/remote-cli-ids.js'; import { mountReactPage, type PageDisposer } from './react-mount.js'; import { useT } from './react-hooks.js'; +import { DropdownField, ModelPickerField, type DropdownFieldOption } from './field-controls.js'; import { MentionMock, ModeOptionGroup, @@ -489,154 +490,6 @@ function FieldTitle(props: { children: ReactNode; help?: ReactNode }) { ); } -type DropdownFieldOption = { - value: T; - label: ReactNode; - hint?: ReactNode; - disabled?: boolean; -}; - -export function DropdownField(props: { - dataInput: string; - value: T; - options: DropdownFieldOption[]; - disabled?: boolean; - ariaLabel?: string; - className?: string; - searchable?: boolean; - onChange(value: T): void; -}) { - const tr = useT(); - return ( - <> - - - - ); -} - -const MODEL_PICKER_CUSTOM = '__custom__'; - -/** - * 模型选择器:下拉候选(静态精选 + live 探测合并,由调用方 mergeModelCandidates 算好) - * + 「自定义模型…」自由输入。模型列表会过期,候选永不锁死。 - * - value='' 表示跟随 CLI 默认,菜单显示 defaultLabel; - * - value 非空但不在候选中时,把当前值作为额外选项插在最前(旧配置/自定义值可见); - * - 选中 customLabel 切到自定义输入模式(datalist 仍挂全部候选做自动补全), - * 返回按钮切回下拉模式; - * - busy(live 探测进行中)只在控件下方显示小转圈,不禁用选择。 - */ -export function ModelPickerField(props: { - value: string; - onChange(next: string): void; - options: readonly string[]; - disabled?: boolean; - busy?: boolean; - dataInput: string; - ariaLabel: string; - defaultLabel: string; - customLabel: string; - includeDefault?: boolean; - detectedCount?: number; - detectedLabel?: string; - /** 下拉菜单样式类:defaults 页传 bd-field-menu,onboarding 传 onboarding-menu。 */ - menuClassName?: string; -}): React.JSX.Element { - const tr = useT(); - const [customMode, setCustomMode] = useState(false); - const datalistId = useId(); - const current = props.value; - const dropdownOptions = useMemo(() => { - const opts: { value: string; label: ReactNode }[] = []; - if (props.includeDefault) opts.push({ value: '', label: props.defaultLabel }); - if (current && !props.options.includes(current)) { - opts.push({ value: current, label: current }); - } - for (const item of props.options) opts.push({ value: item, label: item }); - opts.push({ value: MODEL_PICKER_CUSTOM, label: props.customLabel }); - return opts; - }, [current, props.options, props.customLabel, props.includeDefault, props.defaultLabel]); - - return ( - - {customMode ? ( - - props.onChange(event.currentTarget.value)} - /> - - - {props.options.map(item => - - ) : ( - <> - - id={`bd-menu-${props.dataInput}`} - className={['bd-model-menu', props.menuClassName].filter(Boolean).join(' ')} - ariaLabel={props.ariaLabel} - disabled={props.disabled} - label={current || props.defaultLabel} - value={current} - options={dropdownOptions} - searchable - searchPlaceholder={tr('common.dropdownSearch')} - searchEmptyLabel={tr('common.dropdownSearchEmpty')} - onChange={next => { - if (next === MODEL_PICKER_CUSTOM) { - setCustomMode(true); - return; - } - props.onChange(next); - }} - /> - {/* 与 DropdownField 同款 data-input 锚点:既有测试/自动化经它读写当前值 */} - props.onChange(event.currentTarget.value)} - /> - - )} - {props.busy - ? - : null} - {typeof props.detectedCount === 'number' && props.detectedCount > 0 && props.detectedLabel - ? {props.detectedLabel} - : null} - - ); -} - function ToggleRow(props: { checked: boolean; disabled?: boolean; diff --git a/src/dashboard/web/bot-onboarding.tsx b/src/dashboard/web/bot-onboarding.tsx index 7372369d54..c7458368b3 100644 --- a/src/dashboard/web/bot-onboarding.tsx +++ b/src/dashboard/web/bot-onboarding.tsx @@ -2,7 +2,7 @@ import type React from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; import { DropdownMenu } from './dashboard-components.js'; import { fetchDetectedModels, mergeModelCandidates, modelSuggestionsForOption } from './bot-defaults.js'; -import { ModelPickerField } from './bot-defaults-page.js'; +import { ModelPickerField } from './field-controls.js'; import { confirm } from './confirm-modal.js'; import { t } from './ui.js'; diff --git a/src/dashboard/web/field-controls.tsx b/src/dashboard/web/field-controls.tsx new file mode 100644 index 0000000000..beb45f2a92 --- /dev/null +++ b/src/dashboard/web/field-controls.tsx @@ -0,0 +1,155 @@ +import { useId, useMemo, useState, type ReactNode } from 'react'; +import { DropdownMenu, dropdownLabel } from './dashboard-components.js'; +import { useT } from './react-hooks.js'; + +// Form controls shared by the bot defaults page, bot onboarding and group +// default models. Kept out of bot-defaults-page.tsx so the statically loaded +// onboarding flow does not pull that whole (lazy-loaded) page into app.js. + +export type DropdownFieldOption = { + value: T; + label: ReactNode; + hint?: ReactNode; + disabled?: boolean; +}; + +export function DropdownField(props: { + dataInput: string; + value: T; + options: DropdownFieldOption[]; + disabled?: boolean; + ariaLabel?: string; + className?: string; + searchable?: boolean; + onChange(value: T): void; +}) { + const tr = useT(); + return ( + <> + + + + ); +} + +const MODEL_PICKER_CUSTOM = '__custom__'; + +/** + * 模型选择器:下拉候选(静态精选 + live 探测合并,由调用方 mergeModelCandidates 算好) + * + 「自定义模型…」自由输入。模型列表会过期,候选永不锁死。 + * - value='' 表示跟随 CLI 默认,菜单显示 defaultLabel; + * - value 非空但不在候选中时,把当前值作为额外选项插在最前(旧配置/自定义值可见); + * - 选中 customLabel 切到自定义输入模式(datalist 仍挂全部候选做自动补全), + * 返回按钮切回下拉模式; + * - busy(live 探测进行中)只在控件下方显示小转圈,不禁用选择。 + */ +export function ModelPickerField(props: { + value: string; + onChange(next: string): void; + options: readonly string[]; + disabled?: boolean; + busy?: boolean; + dataInput: string; + ariaLabel: string; + defaultLabel: string; + customLabel: string; + includeDefault?: boolean; + detectedCount?: number; + detectedLabel?: string; + /** 下拉菜单样式类:defaults 页传 bd-field-menu,onboarding 传 onboarding-menu。 */ + menuClassName?: string; +}): React.JSX.Element { + const tr = useT(); + const [customMode, setCustomMode] = useState(false); + const datalistId = useId(); + const current = props.value; + const dropdownOptions = useMemo(() => { + const opts: { value: string; label: ReactNode }[] = []; + if (props.includeDefault) opts.push({ value: '', label: props.defaultLabel }); + if (current && !props.options.includes(current)) { + opts.push({ value: current, label: current }); + } + for (const item of props.options) opts.push({ value: item, label: item }); + opts.push({ value: MODEL_PICKER_CUSTOM, label: props.customLabel }); + return opts; + }, [current, props.options, props.customLabel, props.includeDefault, props.defaultLabel]); + + return ( + + {customMode ? ( + + props.onChange(event.currentTarget.value)} + /> + + + {props.options.map(item => + + ) : ( + <> + + id={`bd-menu-${props.dataInput}`} + className={['bd-model-menu', props.menuClassName].filter(Boolean).join(' ')} + ariaLabel={props.ariaLabel} + disabled={props.disabled} + label={current || props.defaultLabel} + value={current} + options={dropdownOptions} + searchable + searchPlaceholder={tr('common.dropdownSearch')} + searchEmptyLabel={tr('common.dropdownSearchEmpty')} + onChange={next => { + if (next === MODEL_PICKER_CUSTOM) { + setCustomMode(true); + return; + } + props.onChange(next); + }} + /> + {/* 与 DropdownField 同款 data-input 锚点:既有测试/自动化经它读写当前值 */} + props.onChange(event.currentTarget.value)} + /> + + )} + {props.busy + ? + : null} + {typeof props.detectedCount === 'number' && props.detectedCount > 0 && props.detectedLabel + ? {props.detectedLabel} + : null} + + ); +} diff --git a/src/dashboard/web/group-default-models.tsx b/src/dashboard/web/group-default-models.tsx index add118c844..a94fc2bf3b 100644 --- a/src/dashboard/web/group-default-models.tsx +++ b/src/dashboard/web/group-default-models.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { groupModelSettings, type GroupDefaultModels, type GroupModelSettings } from '../../core/group-default-models.js'; import { reasoningEffortsForCliModel } from '../../services/codex-reasoning-effort.js'; import { useT } from './react-hooks.js'; -import { DropdownField, ModelPickerField } from './bot-defaults-page.js'; +import { DropdownField, ModelPickerField } from './field-controls.js'; import { fetchCliOptions, fetchDetectedModels, mergeModelCandidates, modelSuggestionsForOption, selectedCliOption } from './bot-defaults.js'; type Props = { diff --git a/test/dashboard-bot-defaults-model-picker.test.ts b/test/dashboard-bot-defaults-model-picker.test.ts index 688f87f08a..b93e64be7c 100644 --- a/test/dashboard-bot-defaults-model-picker.test.ts +++ b/test/dashboard-bot-defaults-model-picker.test.ts @@ -9,6 +9,7 @@ import { const page = readFileSync(new URL('../src/dashboard/web/bot-defaults-page.tsx', import.meta.url), 'utf8'); const onboarding = readFileSync(new URL('../src/dashboard/web/bot-onboarding.tsx', import.meta.url), 'utf8'); +const fieldControls = readFileSync(new URL('../src/dashboard/web/field-controls.tsx', import.meta.url), 'utf8'); const i18n = readFileSync(new URL('../src/dashboard/web/i18n.ts', import.meta.url), 'utf8'); const cliState: CliOptionsState = { @@ -19,15 +20,18 @@ const cliState: CliOptionsState = { // 源码断言:两处模型输入都换成 ModelPickerField,旧的裸文本框 + datalist 已移除。 describe('ModelPickerField 源码接线', () => { - it('bot-defaults-page 导出 ModelPickerField,BotAgentSection 使用它', () => { - expect(page).toMatch(/export function ModelPickerField\b/); + it('field-controls 导出 ModelPickerField,BotAgentSection 使用它', () => { + expect(fieldControls).toMatch(/export function ModelPickerField\b/); const agentSection = page.slice(page.indexOf('export function BotAgentSection')); expect(agentSection).toContain(' { - expect(onboarding).toContain("import { ModelPickerField } from './bot-defaults-page.js';"); + it('onboarding 也使用 ModelPickerField,且不静态依赖懒加载的 bot-defaults-page', () => { + // onboarding 被 app.tsx 静态引入:若它从 bot-defaults-page 取控件,整页 + // (8k 行)会被打进首屏 chunk,路由级懒加载失效。 + expect(onboarding).toContain("import { ModelPickerField } from './field-controls.js';"); + expect(onboarding).not.toContain("from './bot-defaults-page.js'"); expect(onboarding).toContain('