-
Notifications
You must be signed in to change notification settings - Fork 839
fix(agent-core): cap foreground shell output to prevent OOM crash #1285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@moonshot-ai/agent-core": patch | ||
| --- | ||
|
|
||
| Cap the output a single foreground shell command may stream so a runaway command can no longer crash the process. A command that produces a very large or unbounded amount of output (e.g. `b3sum --length 18446744073709551615`) previously grew the live-output buffer until Node aborted with a JavaScript heap out-of-memory error; it is now gracefully terminated once its output exceeds 16 MiB, and the result explains how to redirect large output to a file instead. The per-task output ring buffer is also maintained in O(1) per chunk rather than O(n²). Background tasks are unaffected. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
172 changes: 172 additions & 0 deletions
172
packages/agent-core/test/agent/background/output-limit.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| /** | ||
| * Foreground output ceiling. | ||
| * | ||
| * A single non-detached command that streams more output than the per-command | ||
| * limit must be force-terminated instead of growing the (unbounded) | ||
| * live-forward buffer until the process runs out of memory. Detached | ||
| * (background) tasks are exempt: their output is ring-buffered and spilled to | ||
| * disk, so it never accumulates in memory. | ||
| */ | ||
|
|
||
| import { mkdtempSync, rmSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { Readable, type Writable } from 'node:stream'; | ||
|
|
||
| import type { KaosProcess } from '@moonshot-ai/kaos'; | ||
| import { join } from 'pathe'; | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { ProcessBackgroundTask } from '../../../src/agent/background'; | ||
| import { createBackgroundManager, waitForTerminal } from './helpers'; | ||
|
|
||
| const MiB = 1024 * 1024; | ||
| const LIMIT_BYTES = 16 * MiB; | ||
|
|
||
| /** | ||
| * A process that streams `chunks` of stdout, then exits 0 on its own — unless | ||
| * it is killed first, in which case `wait()` resolves with the signal's exit | ||
| * code and the stream is destroyed (simulating the child dying on SIGTERM). | ||
| */ | ||
| function streamingProcess(chunks: string[]): { | ||
| proc: KaosProcess; | ||
| kill: ReturnType<typeof vi.fn>; | ||
| } { | ||
| const stdout = Readable.from(chunks); | ||
| const stderr = Readable.from([]); | ||
| let resolveWait!: (code: number) => void; | ||
| const waitP = new Promise<number>((resolve) => { | ||
| resolveWait = resolve; | ||
| }); | ||
| stdout.on('end', () => { | ||
| resolveWait(0); | ||
| }); | ||
| const kill = vi.fn(async (signal: string) => { | ||
| stdout.destroy(); | ||
| resolveWait(signal === 'SIGKILL' ? 137 : 143); | ||
| }); | ||
| const proc = { | ||
| stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, | ||
| stdout, | ||
| stderr, | ||
| pid: 4242, | ||
| exitCode: null, | ||
| wait: () => waitP, | ||
| kill, | ||
| dispose: vi.fn().mockResolvedValue(undefined), | ||
| } as unknown as KaosProcess; | ||
| return { proc, kill }; | ||
| } | ||
|
|
||
| /** | ||
| * A process that keeps streaming all of `chunks` regardless of SIGTERM (only | ||
| * SIGKILL stops it) — simulating a producer that ignores the graceful stop and | ||
| * keeps writing through the SIGTERM grace window. | ||
| */ | ||
| function sigtermIgnoringProcess(chunks: string[]): { proc: KaosProcess; kill: ReturnType<typeof vi.fn> } { | ||
| const stdout = Readable.from(chunks); | ||
| const stderr = Readable.from([]); | ||
| let resolveWait!: (code: number) => void; | ||
| const waitP = new Promise<number>((resolve) => { | ||
| resolveWait = resolve; | ||
| }); | ||
| stdout.on('end', () => { | ||
| resolveWait(0); | ||
| }); | ||
| const kill = vi.fn(async (signal: string) => { | ||
| if (signal === 'SIGKILL') { | ||
| stdout.destroy(); | ||
| resolveWait(137); | ||
| } | ||
| // SIGTERM is intentionally ignored. | ||
| }); | ||
| const proc = { | ||
| stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, | ||
| stdout, | ||
| stderr, | ||
| pid: 4243, | ||
| exitCode: null, | ||
| wait: () => waitP, | ||
| kill, | ||
| dispose: vi.fn().mockResolvedValue(undefined), | ||
| } as unknown as KaosProcess; | ||
| return { proc, kill }; | ||
| } | ||
|
|
||
| describe('BackgroundManager — foreground output ceiling', () => { | ||
| it('terminates a foreground command that exceeds the output limit and stops forwarding', async () => { | ||
| const { manager } = createBackgroundManager(); | ||
| // 20 MiB total, well past the 16 MiB ceiling. | ||
| const chunks = Array.from({ length: 20 }, () => 'x'.repeat(MiB)); | ||
| const { proc, kill } = streamingProcess(chunks); | ||
|
|
||
| let forwardedChars = 0; | ||
| const onOutput = vi.fn((_kind: 'stdout' | 'stderr', text: string) => { | ||
| forwardedChars += text.length; | ||
| }); | ||
|
|
||
| const taskId = manager.registerTask( | ||
| new ProcessBackgroundTask(proc, 'b3sum --length 18446744073709551615', 'hash', onOutput), | ||
| { detached: false, signal: new AbortController().signal, timeoutMs: 60_000 }, | ||
| ); | ||
|
|
||
| const info = await waitForTerminal(manager, taskId); | ||
|
|
||
| expect(info?.status).toBe('killed'); | ||
| expect(info?.stopReason ?? '').toMatch(/output limit/i); | ||
| expect(kill).toHaveBeenCalledWith('SIGTERM'); | ||
| // The live-forward path is capped at the ceiling rather than draining the | ||
| // full 20 MiB into the (unbounded) transcript/stderr buffer. | ||
| expect(forwardedChars).toBeLessThanOrEqual(LIMIT_BYTES); | ||
| }); | ||
|
|
||
| it('does not terminate a detached (background) task for the same output', async () => { | ||
| const { manager } = createBackgroundManager(); | ||
| const chunks = Array.from({ length: 20 }, () => 'x'.repeat(MiB)); | ||
| const { proc, kill } = streamingProcess(chunks); | ||
|
|
||
| const taskId = manager.registerTask(new ProcessBackgroundTask(proc, 'producer', 'bg'), { | ||
| detached: true, | ||
| timeoutMs: 60_000, | ||
| }); | ||
|
|
||
| const info = await waitForTerminal(manager, taskId); | ||
|
|
||
| expect(info?.status).toBe('completed'); | ||
| expect(kill).not.toHaveBeenCalledWith('SIGTERM'); | ||
| }); | ||
|
|
||
| it('stops enqueuing output to disk once the foreground cap trips', async () => { | ||
| const sessionDir = mkdtempSync(join(tmpdir(), 'bpm-limit-')); | ||
| try { | ||
| const { manager, persistence } = createBackgroundManager({ sessionDir }); | ||
| let persistedChars = 0; | ||
| const spy = vi | ||
| .spyOn(persistence!, 'appendTaskOutput') | ||
| .mockImplementation(async (_id: string, chunk: string) => { | ||
| persistedChars += chunk.length; | ||
| }); | ||
|
|
||
| // 20 MiB, and the producer ignores SIGTERM so it keeps writing through | ||
| // the whole grace window. | ||
| const chunks = Array.from({ length: 20 }, () => 'x'.repeat(MiB)); | ||
| const { proc } = sigtermIgnoringProcess(chunks); | ||
|
|
||
| const taskId = manager.registerTask( | ||
| new ProcessBackgroundTask(proc, 'runaway', 'hash', () => {}), | ||
| { detached: false, signal: new AbortController().signal, timeoutMs: 60_000 }, | ||
| ); | ||
|
|
||
| const info = await waitForTerminal(manager, taskId); | ||
|
|
||
| expect(info?.status).toBe('killed'); | ||
| // Before the fix every chunk of the 20 MiB is enqueued into the disk | ||
| // write chain (retaining each string until its write drains); afterwards | ||
| // enqueuing stops at the ceiling so the chain cannot grow unbounded. | ||
| expect(persistedChars).toBeLessThanOrEqual(17 * MiB); | ||
|
|
||
| spy.mockRestore(); | ||
| } finally { | ||
| rmSync(sessionDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a foreground command crosses the 16 MiB cap but keeps writing during the SIGTERM grace period, this check only suppresses
onOutput; every later data event still runssink.appendOutput(chunk)first. In normal persisted sessions that continues to enqueue each chunk into the task output write chain, so a producer that ignores SIGTERM can still accumulate large strings/promises until SIGKILL and hit the same OOM class this change is meant to prevent. Stop appending/reading once the cap has tripped, or destroy/pause the streams before queuing more output.Useful? React with 👍 / 👎.