From 6cc71fec82d6de13d299efa0eff023ea6b1c50dd Mon Sep 17 00:00:00 2001 From: xinggitxing Date: Sun, 24 May 2026 02:15:34 +0800 Subject: [PATCH] perf: convert runtime context probes from sync to async with caching and prewarm The synchronous getRuntimeContext() spawned 5 Git Bash child_process calls sequentially on Windows, blocking the event loop ~12s on startup. Convert all probes (uname, python3 --version, node --version, rg, jq) to async, running them in parallel via Promise.all. Cache the result so subsequent calls are instant. Add prewarmRuntimeContext() called from the SessionManager constructor so computation starts while the user is typing. Also adds an error .catch() to prewarmRuntimeContext to log background failures instead of silently swallowing rejected promises. --- src/prompt.ts | 178 ++++++++++++++++++++++++--------------- src/session.ts | 5 +- src/tests/prompt.test.ts | 4 +- 3 files changed, 116 insertions(+), 71 deletions(-) diff --git a/src/prompt.ts b/src/prompt.ts index 717991b..8691e7d 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -1,13 +1,19 @@ -import { execFileSync, execSync } from "child_process"; +import { exec, execFile } from "child_process"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { fileURLToPath } from "url"; +import { promisify } from "util"; import ejs from "ejs"; import type { SessionMessage } from "./session"; import { findGitBashPath, resolveShellPath } from "./common/shell-utils"; import { supportsMultimodal } from "./common/model-capabilities"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const execFileAsync = promisify(execFile) as (...args: any[]) => Promise<{ stdout: string; stderr: string }>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const execAsync = promisify(exec) as (...args: any[]) => Promise<{ stdout: string; stderr: string }>; + const COMPACT_PROMPT_BASE = `Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context. @@ -186,105 +192,141 @@ export function getCompactPrompt(sessionMessages: SessionMessage[]): string { return `${COMPACT_PROMPT_BASE}\n\nconversation below:\n\n\`\`\`jsonl\n${jsonl}\n\`\`\``; } -export function getRuntimeContext(projectRoot: string, model?: string): string { - const uname = getUnameInfo(); - const shellPath = getShellPathInfo(); - const shellModeOpts = process.platform === "win32" ? { "shell mode": "git-bash" } : {}; - const runtimeVersions = getRuntimeVersionInfo(); - const env = { - "root path": projectRoot, - pwd: projectRoot, - homedir: os.homedir(), - "system info": uname, - "shell path": shellPath, - ...shellModeOpts, - ...runtimeVersions, - "command installed": { - ripgrep: checkToolInstalled("rg"), - jq: checkToolInstalled("jq"), - }, - }; - return `${getCurrentDateAndModelPrompt(model)} - -# Local Workspace Environment +// ─── Async Runtime Context (cached, non-blocking) ───────────────────────── +// The synchronous version blocks the event loop ~12s on Windows due to 5 Git +// Bash spawns. This async+cached version runs probes in parallel and caches +// the result so createSession() never blocks. -\`\`\`json -${JSON.stringify(env, null, 2)} -\`\`\``; -} +let runtimeEnvJsonPromise: Promise | null = null; +let runtimeEnvJsonCached: string | null = null; -function checkToolInstalled(tool: string): boolean { +async function getUnameInfoAsync(): Promise { try { if (process.platform === "win32") { const bashPath = findGitBashPath(); - execFileSync(bashPath, ["-lc", `command -v ${shellSingleQuote(tool)}`], { + const { stdout } = await execFileAsync(bashPath, ["-lc", "uname -a"], { encoding: "utf8", - stdio: "ignore", windowsHide: true, }); - return true; + return stdout.trim(); } - execSync(`command -v ${tool}`, { encoding: "utf8", stdio: "ignore" }); - return true; + const { stdout } = await execAsync("uname -a", { encoding: "utf8" }); + return stdout.trim(); } catch { - return false; + return `${os.type()} ${os.release()} ${os.arch()}`; } } -function getShellPathInfo(): string { +async function getCommandVersionAsync(command: string, args: string[]): Promise { try { - return resolveShellPath(); - } catch (error) { - return error instanceof Error ? error.message : String(error); + if (process.platform === "win32") { + const bashPath = findGitBashPath(); + const commandText = [command, ...args].map(shellSingleQuote).join(" "); + const { stdout } = await execFileAsync(bashPath, ["-lc", `${commandText} 2>&1`], { + encoding: "utf8", + windowsHide: true, + }); + return stdout.trim(); + } + const commandText = [command, ...args].map(shellSingleQuote).join(" "); + const { stdout } = await execAsync(`${commandText} 2>&1`, { encoding: "utf8" }); + return stdout.trim(); + } catch { + return null; } } -function shellSingleQuote(value: string): string { - return `'${value.replace(/'/g, "'\"'\"'")}'`; +async function checkToolInstalledAsync(tool: string): Promise { + try { + if (process.platform === "win32") { + const bashPath = findGitBashPath(); + await execFileAsync(bashPath, ["-lc", `command -v ${shellSingleQuote(tool)}`], { + encoding: "utf8", + stdio: "ignore", + windowsHide: true, + }); + return true; + } + await execAsync(`command -v ${tool}`, { encoding: "utf8", stdio: "ignore" }); + return true; + } catch { + return false; + } } -function getRuntimeVersionInfo(): Record { +async function getRuntimeVersionInfoAsync(): Promise> { const versions: Record = {}; - const pythonVersion = getCommandVersion("python3", ["--version"]); - const nodeVersion = getCommandVersion("node", ["--version"]); - + const [pythonVersion, nodeVersion] = await Promise.all([ + getCommandVersionAsync("python3", ["--version"]), + getCommandVersionAsync("node", ["--version"]), + ]); if (pythonVersion) { versions["python3 version"] = pythonVersion.replace(/^Python\s+/i, ""); } if (nodeVersion) { versions["node version"] = nodeVersion; } - return versions; } -function getCommandVersion(command: string, args: string[]): string | null { - try { - const commandText = [command, ...args].map(shellSingleQuote).join(" "); - if (process.platform === "win32") { - return execFileSync(findGitBashPath(), ["-lc", `${commandText} 2>&1`], { - encoding: "utf8", - windowsHide: true, - }).trim(); - } - return execSync(`${commandText} 2>&1`, { encoding: "utf8" }).trim(); - } catch { - return null; - } +async function computeRuntimeEnvJson(projectRoot: string): Promise { + const [uname, shellPath, runtimeVersions, hasRg, hasJq] = await Promise.all([ + getUnameInfoAsync(), + (async () => { + try { + return resolveShellPath(); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + })(), + getRuntimeVersionInfoAsync(), + checkToolInstalledAsync("rg"), + checkToolInstalledAsync("jq"), + ]); + + const shellModeOpts = process.platform === "win32" ? { "shell mode": "git-bash" } : {}; + const env = { + "root path": projectRoot, + pwd: projectRoot, + homedir: os.homedir(), + "system info": uname, + "shell path": shellPath, + ...shellModeOpts, + ...runtimeVersions, + "command installed": { + ripgrep: hasRg, + jq: hasJq, + }, + }; + const json = JSON.stringify(env, null, 2); + runtimeEnvJsonCached = json; + return json; } -function getUnameInfo(): string { - try { - if (process.platform === "win32") { - return execFileSync(findGitBashPath(), ["-lc", "uname -a"], { - encoding: "utf8", - windowsHide: true, - }).trim(); - } - return execSync("uname -a", { encoding: "utf8" }).trim(); - } catch { - return `${os.type()} ${os.release()} ${os.arch()}`; +async function getRuntimeEnvJson(projectRoot: string): Promise { + if (runtimeEnvJsonCached) return runtimeEnvJsonCached; + if (!runtimeEnvJsonPromise) { + runtimeEnvJsonPromise = computeRuntimeEnvJson(projectRoot); } + return runtimeEnvJsonPromise; +} + +/** Start pre-computing the runtime env in the background (called at startup). */ +export function prewarmRuntimeContext(projectRoot: string): void { + void getRuntimeEnvJson(projectRoot).catch((err) => { + console.error("[prewarmRuntimeContext] Failed to pre-compute runtime environment:", err); + }); +} + +export async function getRuntimeContext(projectRoot: string, model?: string): Promise { + const envJson = await getRuntimeEnvJson(projectRoot); + return `${getCurrentDateAndModelPrompt(model)}\n\n# Local Workspace Environment\n\n\`\`\`json\n${envJson}\n\`\`\``; +} + +// ─── Sync helpers (kept for backward compat) ────────────────────────────── + +function shellSingleQuote(value: string): string { + return `'${value.replace(/'/g, "'\"'\"'")}'`; } function getExtensionRoot(): string { diff --git a/src/session.ts b/src/session.ts index 54340e7..a3e7624 100644 --- a/src/session.ts +++ b/src/session.ts @@ -15,6 +15,7 @@ import { getRuntimeContext, getSystemPrompt, getTools, + prewarmRuntimeContext, type ToolDefinition, } from "./prompt"; import { @@ -278,6 +279,8 @@ export class SessionManager { this.onProcessStdout = options.onProcessStdout; this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager); this.mcpManager.prepare(this.getResolvedSettings().mcpServers); + // Kick off async runtime context pre-computation while user is typing. + prewarmRuntimeContext(this.projectRoot); } async initMcpServers(servers?: Record): Promise { @@ -964,7 +967,7 @@ The candidate skills are as follows:\n\n`; const runtimeContextMessage = this.buildSystemMessage( sessionId, - getRuntimeContext(this.projectRoot, promptToolOptions.model) + await getRuntimeContext(this.projectRoot, promptToolOptions.model) ); this.appendSessionMessage(sessionId, runtimeContextMessage); diff --git a/src/tests/prompt.test.ts b/src/tests/prompt.test.ts index cc86712..044b9bb 100644 --- a/src/tests/prompt.test.ts +++ b/src/tests/prompt.test.ts @@ -55,10 +55,10 @@ test("getSystemPrompt does not include current date guidance", () => { assert.equal(prompt.includes(expected), false); }); -test("getRuntimeContext includes current date and model guidance", () => { +test("getRuntimeContext includes current date and model guidance", async () => { const now = new Date(); const expectedDate = `今天是${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日。随着对话的进行,时间在流逝。`; - const prompt = getRuntimeContext("/tmp/project", "deepseek-v4-pro"); + const prompt = await getRuntimeContext("/tmp/project", "deepseek-v4-pro"); assert.equal(prompt.includes(expectedDate), true); assert.equal(prompt.includes("当前LLM模型为deepseek-v4-pro,对话中可通过/model命令切换模型。"), true); assert.equal(prompt.includes("# Local Workspace Environment"), true);