From 2159f56386e53af57209a749510096e8a717f38f Mon Sep 17 00:00:00 2001 From: hezhiyang2000 <56215568+hezhiyang2000@users.noreply.github.com> Date: Wed, 27 May 2026 14:57:46 +0800 Subject: [PATCH 1/5] fix: add EventLoopKeepalive to eliminate idle busy-wait Root cause: Bun 1.3.x (JavaScriptCore) busy-waits when the only pending work is an unresolved Promise. A setInterval keepalive keeps the event loop in epoll_wait instead of userspace spinning. - EventLoopKeepalive: setInterval-based keepalive (re-arms after each firing, addressing the bot review concern about setTimeout expiry) - keepaliveWhile(): wrapper to await a Promise with keepalive active - Applied to getUserInput() in main.ts - Retains yieldIfDue() and ExponentialYield from #1396 Idle CPU drops from ~100% to ~0% (wchan=do_epoll_wait). --- packages/agent/src/index.ts | 2 + packages/agent/src/utils/yield.ts | 99 +++++++++++++++++++++++++++---- packages/coding-agent/src/main.ts | 9 +-- 3 files changed, 91 insertions(+), 19 deletions(-) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 30331ee37..23f1254ec 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -17,3 +17,5 @@ export * from "./telemetry"; export * from "./thinking"; // Types export * from "./types"; +// Yield utilities for Bun event-loop busy-wait prevention +export * from "./utils/yield"; diff --git a/packages/agent/src/utils/yield.ts b/packages/agent/src/utils/yield.ts index 376651a0c..4c05dae30 100644 --- a/packages/agent/src/utils/yield.ts +++ b/packages/agent/src/utils/yield.ts @@ -1,23 +1,96 @@ /** * Cooperative yield utility for preventing Bun event-loop busy-wait. * - * Bun 1.3.x (JavaScriptCore) does not automatically yield to the kernel when - * the microtask queue is continuously non-empty. In long-running agent loops - * (LLM streaming, tool execution) this causes ~100% CPU usage even when the - * process is simply waiting for I/O. + * ## Root Cause * - * `yieldIfDue()` uses a compensated sleep that retries `scheduler.wait()` - * until the requested wall-clock duration has actually elapsed. This is - * necessary because napi callbacks (e.g. `Shell.run` chunk callbacks via - * `uv_async_send`) can wake the event loop prematurely, causing the timer - * to return after only ~1–2 ms regardless of the requested duration. + * Bun 1.3.x (JavaScriptCore) event loop busy-waits (spins in userspace) + * when the only pending work is an unresolved Promise — even if there are + * active I/O watchers (stdin, child process pipes, etc.). The event loop + * continuously polls for microtask resolution instead of blocking in + * `epoll_wait`, consuming ~100% of a CPU core. * - * The minimum effective sleep is ~20 ms per yield; at ~30 yield calls/second - * this gives 600 ms/second of kernel sleep → ~40% CPU under active load. + * This affects any `await` on a never-resolved Promise, including: + * - `Promise.withResolvers()` used for user input callbacks + * - `await proc.exited` for long-running child processes + * - Agent loop iterations waiting for the next tool call + * + * ## Fix + * + * A recurring `setInterval` keeps the event loop sleeping in `epoll_wait`. + * The `EventLoopKeepalive` class and `keepaliveWhile()` wrapper provide a + * clean way to install and clean up this keepalive timer. + * + * The older `yieldIfDue()` and `ExponentialYield` approaches (compensated + * sleep loops) are retained for the agent-loop hot-path where Promises + * resolve frequently and the keepalive alone is insufficient. */ import { scheduler } from "node:timers/promises"; +// --------------------------------------------------------------------------- +// EventLoopKeepalive — the primary fix for idle-state busy-wait +// --------------------------------------------------------------------------- + +const KEEPALIVE_INTERVAL_MS = 86_400_000; // 24 hours — re-armed each interval + +/** + * Manages a recurring `setInterval` that prevents the Bun event loop + * from busy-waiting when only unresolved Promises are pending. + * + * Uses `setInterval` (not `setTimeout`) so the keepalive automatically + * re-arms after each firing. This ensures that even sessions left idle + * for longer than one interval remain covered until `dispose()` is called. + * + * ```ts + * const ka = new EventLoopKeepalive(); + * await someNeverResolvingPromise; // Without keepalive: 100% CPU + * ka.dispose(); // Clean up when done + * ``` + */ +export class EventLoopKeepalive { + #timer: ReturnType | undefined; + + constructor() { + this.#timer = setInterval(() => {}, KEEPALIVE_INTERVAL_MS); + } + + /** Dispose the keepalive timer. Call when the awaited Promise resolves. */ + dispose(): void { + if (this.#timer !== undefined) { + clearInterval(this.#timer); + this.#timer = undefined; + } + } +} + +/** + * Await a Promise with an event-loop keepalive active. + * + * This is the primary fix for Bun's busy-wait on unresolved Promises. + * Use it wherever you `await` a Promise that may remain unresolved for + * an extended period (user input, long-running subprocess, etc.). + * + * ```ts + * // Before (100% CPU while waiting): + * const input = await mode.getUserInput(); + * + * // After (proper epoll_wait sleep): + * const input = await keepaliveWhile(mode.getUserInput()); + * ``` + */ +export async function keepaliveWhile(promise: Promise): Promise { + const ka = new EventLoopKeepalive(); + try { + return await promise; + } finally { + ka.dispose(); + } +} + +// --------------------------------------------------------------------------- +// yieldIfDue — retained for agent-loop hot-path +// --------------------------------------------------------------------------- + const YIELD_SLEEP_MS = 20; const YIELD_INTERVAL_MS = 50; @@ -63,7 +136,9 @@ export async function yieldIfDue(): Promise { lastYieldAt = Date.now(); } -// --- ExponentialYield --- +// --------------------------------------------------------------------------- +// ExponentialYield — retained for bash-executor long waits +// --------------------------------------------------------------------------- const EXP_DEFAULT_MIN_MS = 20; const EXP_DEFAULT_MAX_MS = 10_000; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index c22dab6d2..5a33ee6bc 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -62,6 +62,7 @@ import { resolvePromptInput } from "./system-prompt"; import type { LspStartupServerInfo } from "./tools"; import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog"; import type { EventBus } from "./utils/event-bus"; +import { keepaliveWhile } from "@oh-my-pi/pi-agent-core"; async function checkForNewVersion(currentVersion: string): Promise { if (!settings.get("startup.checkUpdate")) { @@ -315,7 +316,7 @@ async function runInteractiveMode( } while (true) { - const input = await mode.getUserInput(); + const input = await keepaliveWhile(mode.getUserInput()); await submitInteractiveInput(mode, session, input); } } @@ -529,7 +530,6 @@ async function buildSessionOptions( ): Promise<{ options: CreateAgentSessionOptions }> { const options: CreateAgentSessionOptions = { cwd: parsed.cwd ?? getProjectDir(), - autoApprove: parsed.autoApprove ?? false, }; // Auto-discover SYSTEM.md if no CLI system prompt provided @@ -770,11 +770,6 @@ export async function runRootCommand( const cwd = getProjectDir(); const settingsInstance = deps.settings ?? (await logger.time("settings:init", Settings.init, { cwd })); - if (parsedArgs.approvalMode) { - // Runtime override (not persisted): every settings.get("tools.approvalMode") downstream - // sees this value. The wrapper still honours --auto-approve / --yolo on top of it. - settingsInstance.override("tools.approvalMode", parsedArgs.approvalMode); - } if (parsedArgs.mode === "rpc" || parsedArgs.mode === "rpc-ui" || parsedArgs.mode === "acp") { applyRpcDefaultSettingOverrides(settingsInstance); } From 112693430043d83432265ef8dbcc69cbe20cdf92 Mon Sep 17 00:00:00 2001 From: hezhiyang2000 <56215568+hezhiyang2000@users.noreply.github.com> Date: Wed, 27 May 2026 14:59:08 +0800 Subject: [PATCH 2/5] fix: replace ReturnType with NodeJS.Timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project convention (AGENTS.md) prohibits ReturnType<> — use the concrete type name instead. NodeJS.Timeout matches the existing pattern used throughout the codebase (e.g. interactive-mode.ts). --- packages/agent/src/utils/yield.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent/src/utils/yield.ts b/packages/agent/src/utils/yield.ts index 4c05dae30..8ce2775b8 100644 --- a/packages/agent/src/utils/yield.ts +++ b/packages/agent/src/utils/yield.ts @@ -48,7 +48,7 @@ const KEEPALIVE_INTERVAL_MS = 86_400_000; // 24 hours — re-armed each interval * ``` */ export class EventLoopKeepalive { - #timer: ReturnType | undefined; + #timer: NodeJS.Timeout | undefined; constructor() { this.#timer = setInterval(() => {}, KEEPALIVE_INTERVAL_MS); From 19c9f2d9a84ccce166260b82148707157813aa87 Mon Sep 17 00:00:00 2001 From: hezhiyang2000 <56215568+hezhiyang2000@users.noreply.github.com> Date: Wed, 27 May 2026 15:04:30 +0800 Subject: [PATCH 3/5] fix: restore --auto-approve and --approval-mode handling Rebase on main accidentally dropped these two code paths that exist on the current main branch: - autoApprove field in CreateAgentSessionOptions - settingsInstance.override('tools.approvalMode', ...) for --approval-mode Both are now restored to match upstream main. --- packages/coding-agent/src/main.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 5a33ee6bc..3de89beab 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -530,6 +530,7 @@ async function buildSessionOptions( ): Promise<{ options: CreateAgentSessionOptions }> { const options: CreateAgentSessionOptions = { cwd: parsed.cwd ?? getProjectDir(), + autoApprove: parsed.autoApprove ?? false, }; // Auto-discover SYSTEM.md if no CLI system prompt provided @@ -770,6 +771,11 @@ export async function runRootCommand( const cwd = getProjectDir(); const settingsInstance = deps.settings ?? (await logger.time("settings:init", Settings.init, { cwd })); + if (parsedArgs.approvalMode) { + // Runtime override (not persisted): every settings.get("tools.approvalMode") downstream + // sees this value. The wrapper still honours --auto-approve / --yolo on top of it. + settingsInstance.override("tools.approvalMode", parsedArgs.approvalMode); + } if (parsedArgs.mode === "rpc" || parsedArgs.mode === "rpc-ui" || parsedArgs.mode === "acp") { applyRpcDefaultSettingOverrides(settingsInstance); } From 1abb54a561c52c892ce49ed0f4fa7c82e892a5a9 Mon Sep 17 00:00:00 2001 From: hezhiyang2000 <56215568+hezhiyang2000@users.noreply.github.com> Date: Wed, 27 May 2026 15:09:56 +0800 Subject: [PATCH 4/5] style: fix import order for Biome lint Move keepaliveWhile import to alphabetical position after @oh-my-pi/pi-ai to satisfy Biome organizeImports rule. --- packages/coding-agent/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 3de89beab..a56d6519a 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -10,6 +10,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { createInterface } from "node:readline/promises"; import type { ImageContent } from "@oh-my-pi/pi-ai"; +import { keepaliveWhile } from "@oh-my-pi/pi-agent-core"; import { $env, getProjectDir, @@ -62,7 +63,6 @@ import { resolvePromptInput } from "./system-prompt"; import type { LspStartupServerInfo } from "./tools"; import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog"; import type { EventBus } from "./utils/event-bus"; -import { keepaliveWhile } from "@oh-my-pi/pi-agent-core"; async function checkForNewVersion(currentVersion: string): Promise { if (!settings.get("startup.checkUpdate")) { From 86ab3648f8c4309d024154d69cde9ed59f76334e Mon Sep 17 00:00:00 2001 From: hezhiyang2000 <56215568+hezhiyang2000@users.noreply.github.com> Date: Wed, 27 May 2026 15:14:46 +0800 Subject: [PATCH 5/5] style: fix Biome import order (value imports before type imports) Biome organizeImports sorts value imports before type imports within the same group. keepaliveWhile must come before ImageContent. --- packages/coding-agent/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index a56d6519a..71f9b3ff2 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -9,8 +9,8 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { createInterface } from "node:readline/promises"; -import type { ImageContent } from "@oh-my-pi/pi-ai"; import { keepaliveWhile } from "@oh-my-pi/pi-agent-core"; +import type { ImageContent } from "@oh-my-pi/pi-ai"; import { $env, getProjectDir,