diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2b77152c2..10ecd72a7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ ### Fixed +- Fixed a session-liveness wait that could pin a CPU core at 100% by resampling settled promises in a tight microtask loop; it now yields a real event-loop turn when a queued work item does not converge, restoring RPC responsiveness under load ([#1084](https://github.com/code-yeongyu/senpi/pull/1084)). - The interactive TUI now keeps the working dock painted across adjacent and locally buffered turns, and clears it on the new core `agent_idle` event - emitted only after settlement-deferred turns (TTSR, loop-guard, goal recovery) resolve without starting a run - so the editor/footer no longer bounce at queued-turn boundaries. A buffered prompt consumed with `action: "handled"` (for example a `UserPromptSubmit` hook block) also clears the retained dock, prompt admission failure clears it, and clear-on-shrink reserves the dock's measured height, together eliminating the vertical jitter. - Goal cache-warm notices now render the expected wake time in the user's local system timezone with a short zone label (for example `ready 2026-08-22 16:51 GMT+9 (4m 30s)`), falling back to the legacy UTC shape when local timezone formatting is unavailable ([#1074](https://github.com/code-yeongyu/senpi/pull/1074)). diff --git a/packages/coding-agent/src/core/session-work-barrier.ts b/packages/coding-agent/src/core/session-work-barrier.ts index 4223bf32f..80c7ef600 100644 --- a/packages/coding-agent/src/core/session-work-barrier.ts +++ b/packages/coding-agent/src/core/session-work-barrier.ts @@ -1,3 +1,33 @@ +/** + * Re-sample rounds allowed before the loop starts yielding to the event loop. + * Normal sessions settle within a couple of rounds, so a small budget keeps the + * fast path free of macrotask hops while still catching a runaway queue. + */ +const SETTLE_ROUNDS_BEFORE_YIELD = 16; + +/** + * Yields to a real event-loop turn between re-sample rounds. Awaiting only + * promises keeps work on the microtask queue, which starves timers and IO and + * pins a core at 100% CPU. + * + * A `MessageChannel` message is a genuine macrotask that mocked timers do not + * replace, so the loop keeps yielding under `vi.useFakeTimers()`. `setTimeout` + * and `setImmediate` are both stubbed there and would stall every caller that + * drives retry/compaction recovery on a mocked clock; `process.nextTick` runs + * ahead of the microtask drain and would not relieve the starvation at all. + */ +function yieldToEventLoop(): Promise { + return new Promise((resolve) => { + const channel = new MessageChannel(); + channel.port1.onmessage = () => { + channel.port1.close(); + channel.port2.close(); + resolve(); + }; + channel.port2.postMessage(undefined); + }); +} + export class SessionWorkBarrier { private activeWork: Promise | undefined = undefined; private activeWorkResolve: (() => void) | undefined = undefined; @@ -39,7 +69,7 @@ export class SessionWorkBarrier { } async waitForSettled(getEventQueue: () => Promise): Promise { - while (true) { + for (let round = 0; ; round++) { const eventQueue = getEventQueue(); const work = this.activeWork; @@ -51,6 +81,16 @@ export class SessionWorkBarrier { if (getEventQueue() === eventQueue && !this.activeWork) { return; } + + // The queue moved under us. The first rounds re-sample immediately so a + // continuation scheduled during this turn is still observed as pending — + // that tight re-check is what keeps queued work from being reported as + // settled. Once the queue proves it is not converging, hand control back + // to the event loop so a session that re-chains already-resolved promises + // cannot pin a core at 100% CPU. + if (round >= SETTLE_ROUNDS_BEFORE_YIELD) { + await yieldToEventLoop(); + } } } } diff --git a/packages/coding-agent/test/session-work-barrier.test.ts b/packages/coding-agent/test/session-work-barrier.test.ts new file mode 100644 index 000000000..afeda62d4 --- /dev/null +++ b/packages/coding-agent/test/session-work-barrier.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { SessionWorkBarrier } from "../src/core/session-work-barrier.ts"; + +/** + * `waitForSettled` re-samples the event queue until two consecutive samples agree. + * A session whose queue identity is replaced by already-resolved promises never + * reaches agreement, and because every iteration only awaits microtasks the loop + * never yields to the event loop's timer/IO phases: the process pins one core at + * 100% CPU for as long as the producer keeps re-chaining. This is the observed + * `omo --mode rpc --multi-session` child spin. + */ +describe("SessionWorkBarrier.waitForSettled", () => { + it("returns once the event queue identity is stable and no work is active", async () => { + const barrier = new SessionWorkBarrier(); + const queue = Promise.resolve(); + await barrier.waitForSettled(() => queue); + expect(barrier.hasActiveWork).toBe(false); + }); + + it("waits for active work registered through begin()", async () => { + const barrier = new SessionWorkBarrier(); + const queue = Promise.resolve(); + const finish = barrier.begin(); + expect(barrier.hasActiveWork).toBe(true); + + let settled = false; + const waiter = barrier + .waitForSettled(() => queue) + .then(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + + finish(); + await waiter; + expect(settled).toBe(true); + expect(barrier.hasActiveWork).toBe(false); + }); + + it("keeps the event loop responsive when the queue never stabilizes", async () => { + const barrier = new SessionWorkBarrier(); + // Model the observed spin: a session whose subscriber keeps re-chaining an + // already-resolved queue, so the two samples in one round never agree. + // Awaiting only such promises never leaves the microtask queue, which pins + // a core at 100% CPU and stalls every timer and socket on the process. + let unstableRounds = 0; + const getEventQueue = (): Promise => { + unstableRounds++; + return Promise.resolve(); + }; + + let timerFired = false; + const ticker = setInterval(() => { + timerFired = true; + }, 1); + // Deliberately not awaited: an unstable queue means there is still work to + // drain, so reporting it as settled would be a lie. The contract under test + // is that waiting stays cheap, not that it gives up. + void barrier.waitForSettled(getEventQueue); + + try { + await new Promise((resolve) => { + const deadline = setTimeout(resolve, 50); + deadline.unref?.(); + }); + // A starving loop never lets these timers run at all. + expect(timerFired).toBe(true); + expect(unstableRounds).toBeGreaterThan(0); + } finally { + clearInterval(ticker); + } + }); +});