fix(tui): bounded WSL idle animation CPU
- Removed the Loader backpressure cap so slow ConPTY paints retain the documented proportional duty cycle. - Made WSL terminal-title working state static to avoid a second periodic OSC write loop. Fixes #8012
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed terminal-title spinner writes consuming CPU during WSL/ConPTY agent waits by using the same static working separator as native Windows ([#8012](https://github.com/can1357/oh-my-pi/issues/8012)).
|
||||
|
||||
## [17.2.11] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as path from "node:path";
|
||||
|
||||
import { type Api, type AssistantMessage, completeSimple, type Model } from "@oh-my-pi/pi-ai";
|
||||
import { StreamMarkupHealing } from "@oh-my-pi/pi-ai/utils/stream-markup-healing";
|
||||
import { isConPTYHosted } from "@oh-my-pi/pi-tui";
|
||||
import { isTerminalHeadless, logger, prompt } from "@oh-my-pi/pi-utils";
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
|
||||
@@ -533,6 +534,7 @@ function emitTerminalTitle(): void {
|
||||
terminalTitleRuntime.state,
|
||||
terminalTitleRuntime.frame,
|
||||
terminalTitleRuntime.enabled,
|
||||
isConPTYHosted() ? "win32" : process.platform,
|
||||
);
|
||||
setTerminalTitle(next);
|
||||
}
|
||||
@@ -543,7 +545,7 @@ function stopTerminalTitleSpinner(): void {
|
||||
}
|
||||
|
||||
function startTerminalTitleSpinner(): void {
|
||||
if (process.platform === "win32" || terminalTitleRuntime.timer || !process.stdout.isTTY) return;
|
||||
if (isConPTYHosted() || terminalTitleRuntime.timer || !process.stdout.isTTY) return;
|
||||
terminalTitleRuntime.timer = setInterval(() => {
|
||||
terminalTitleRuntime.frame = (terminalTitleRuntime.frame + 1) % TITLE_SPINNER_FRAMES.length;
|
||||
emitTerminalTitle();
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
setSessionTerminalTitle,
|
||||
setTerminalTitleState,
|
||||
} from "@oh-my-pi/pi-coding-agent/utils/title-generator";
|
||||
import { isConPTYHosted } from "@oh-my-pi/pi-tui";
|
||||
import { setTerminalHeadless } from "@oh-my-pi/pi-utils";
|
||||
import { mockWindowsConsoleTitle, type WindowsConsoleTitleMock } from "./terminal-title-test-utils";
|
||||
|
||||
@@ -117,31 +118,28 @@ describe("disposeTerminalTitleState", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"stops the spinner so no further OSC-title write fires on a tick after dispose",
|
||||
() => {
|
||||
// CONTRACT (the fix): entering `working` arms the spinner interval; once
|
||||
// `disposeTerminalTitleState()` runs, advancing the clock across many tick
|
||||
// periods must produce ZERO additional OSC-title writes. A pending tick
|
||||
// re-emitting the title after teardown is exactly the shell-tab leak.
|
||||
setTerminalTitleState("working");
|
||||
it.skipIf(isConPTYHosted())("stops the spinner so no further OSC-title write fires on a tick after dispose", () => {
|
||||
// CONTRACT (the fix): entering `working` arms the spinner interval; once
|
||||
// `disposeTerminalTitleState()` runs, advancing the clock across many tick
|
||||
// periods must produce ZERO additional OSC-title writes. A pending tick
|
||||
// re-emitting the title after teardown is exactly the shell-tab leak.
|
||||
setTerminalTitleState("working");
|
||||
|
||||
// Control: BEFORE dispose the interval is live — advancing the clock across
|
||||
// several 80ms tick periods DOES emit further OSC-title writes (proves the
|
||||
// timer was actually running, so the post-dispose silence is meaningful and
|
||||
// not a headless/TTY misconfiguration masking all writes).
|
||||
writes.length = 0;
|
||||
vi.advanceTimersByTime(400);
|
||||
const ticksWhileLive = writes.filter(payload => payload.includes(OSC_TITLE_SEQ)).length;
|
||||
expect(ticksWhileLive).toBeGreaterThan(0);
|
||||
// Control: BEFORE dispose the interval is live — advancing the clock across
|
||||
// several 80ms tick periods DOES emit further OSC-title writes (proves the
|
||||
// timer was actually running, so the post-dispose silence is meaningful and
|
||||
// not a headless/TTY misconfiguration masking all writes).
|
||||
writes.length = 0;
|
||||
vi.advanceTimersByTime(400);
|
||||
const ticksWhileLive = writes.filter(payload => payload.includes(OSC_TITLE_SEQ)).length;
|
||||
expect(ticksWhileLive).toBeGreaterThan(0);
|
||||
|
||||
// The fix under test.
|
||||
disposeTerminalTitleState();
|
||||
// The fix under test.
|
||||
disposeTerminalTitleState();
|
||||
|
||||
// After dispose: advance far past many tick periods. No tick may fire.
|
||||
writes.length = 0;
|
||||
vi.advanceTimersByTime(4000);
|
||||
expect(writes.filter(payload => payload.includes(OSC_TITLE_SEQ))).toEqual([]);
|
||||
},
|
||||
);
|
||||
// After dispose: advance far past many tick periods. No tick may fire.
|
||||
writes.length = 0;
|
||||
vi.advanceTimersByTime(4000);
|
||||
expect(writes.filter(payload => payload.includes(OSC_TITLE_SEQ))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -730,6 +730,28 @@ describe("terminal title runtime", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the working title static under WSL", () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalWslDistro = process.env.WSL_DISTRO_NAME;
|
||||
try {
|
||||
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
||||
process.env.WSL_DISTRO_NAME = "Ubuntu";
|
||||
setSessionTerminalTitle("wsl-project");
|
||||
writes.length = 0;
|
||||
|
||||
setTerminalTitleState("working");
|
||||
expect(emittedTitles()).toEqual(["π : wsl-project"]);
|
||||
|
||||
writes.length = 0;
|
||||
vi.advanceTimersByTime(400);
|
||||
expect(writes).toEqual([]);
|
||||
} finally {
|
||||
if (originalWslDistro === undefined) delete process.env.WSL_DISTRO_NAME;
|
||||
else process.env.WSL_DISTRO_NAME = originalWslDistro;
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses SetConsoleTitleW without an OSC write on Windows", () => {
|
||||
const originalPlatform = process.platform;
|
||||
const native = windowsTitleMock;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed slow Loader paints exceeding their cost-aware CPU duty cycle on WSL/ConPTY when a 200 ms backpressure cap was shorter than the proportional delay ([#8012](https://github.com/can1357/oh-my-pi/issues/8012)).
|
||||
|
||||
## [17.2.11] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Text } from "./text";
|
||||
|
||||
const RENDER_INTERVAL_MS = 1000 / 30;
|
||||
const SPINNER_ADVANCE_MS = 80;
|
||||
const MAX_RENDER_BACKPRESSURE_MS = 200;
|
||||
const RENDER_BACKPRESSURE_MULTIPLIER = 9;
|
||||
|
||||
type ColorFn = (str: string) => string;
|
||||
@@ -144,8 +143,8 @@ export class Loader extends Text {
|
||||
if (this.#intervalId !== timer) return;
|
||||
const cadenceDelayMs = Math.max(0, intervalMs - frameCostMs);
|
||||
// Idle for nine times the paint cost to keep animation at or below
|
||||
// 10% CPU, while cheap frames retain their original cadence.
|
||||
const backpressureDelayMs = Math.min(MAX_RENDER_BACKPRESSURE_MS, frameCostMs * RENDER_BACKPRESSURE_MULTIPLIER);
|
||||
// 10% CPU, even when a slow ConPTY write exceeds the normal cadence.
|
||||
const backpressureDelayMs = frameCostMs * RENDER_BACKPRESSURE_MULTIPLIER;
|
||||
this.#scheduleTick(intervalMs, Math.max(cadenceDelayMs, backpressureDelayMs));
|
||||
}, delayMs);
|
||||
this.#intervalId = timer;
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("Loader component", () => {
|
||||
const ui = {
|
||||
synchronizedOutput: true,
|
||||
requestDirectWrite: vi.fn(() => {
|
||||
now += 20;
|
||||
now += 40;
|
||||
}),
|
||||
requestComponentRender: vi.fn(),
|
||||
};
|
||||
@@ -153,9 +153,9 @@ describe("Loader component", () => {
|
||||
vi.advanceTimersByTime(34);
|
||||
expect(ui.requestDirectWrite).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.advanceTimersByTime(170);
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(ui.requestDirectWrite).toHaveBeenCalledTimes(2);
|
||||
vi.advanceTimersByTime(10);
|
||||
vi.advanceTimersByTime(160);
|
||||
expect(ui.requestDirectWrite).toHaveBeenCalledTimes(3);
|
||||
|
||||
loader.stop();
|
||||
|
||||
Reference in New Issue
Block a user