Skip to content

fix: drain performance entry buffer to prevent OOM in long Ink sessions#522

Merged
will-lamerton merged 1 commit into
Nano-Collective:mainfrom
ragini-pandey:fix/perf-buffer-oom-issue-521
May 19, 2026
Merged

fix: drain performance entry buffer to prevent OOM in long Ink sessions#522
will-lamerton merged 1 commit into
Nano-Collective:mainfrom
ragini-pandey:fix/perf-buffer-oom-issue-521

Conversation

@ragini-pandey
Copy link
Copy Markdown
Contributor

Description

Fixes the JavaScript heap OOM crash that occurs during long workflows with many subagent runs. Installs a periodic housekeeping timer that drains Node's global performance entry buffer (marks, measures, and resource timings), preventing it from growing unbounded over the lifetime of the process.

Closes #521

Root Cause

Ink renders via react-reconciler@0.33 — the development build, which is what ships in node_modules. In development mode React calls performance.measure() and performance.mark() on every render to emit user-timing DevTools entries. Node's built-in fetch (undici) similarly pushes a resource-timing entry per HTTP request. Both APIs write to the same global performance entry buffer (perf_hooks).

There is no PerformanceObserver in nanocoder that would drain the buffer, and there is no React DevTools to consume the entries, so they accumulate silently.

At the 1 M entry mark Node logs:

MaxPerformanceEntryBufferExceededWarning: Possible perf_hooks memory leak detected.
1000001 measure entries added to the global performance entry buffer.

After hours of operation (many subagent runs → many renders) the buffer holds millions of objects, V8 mark-compact GC becomes progressively less effective, and the process crashes:

FATAL ERROR: Ineffective mark-compacts near heap limit
Allocation failed - JavaScript heap out of memory

Approach

source/utils/perf-buffer.ts — new standalone helper that installs an unref'd 30-second setInterval which calls:

  • performance.clearMarks()
  • performance.clearMeasures()
  • performance.clearResourceTimings()

Using timer.unref() means the interval never keeps the event loop alive if the app has otherwise finished.

source/cli.tsx — calls installPerfBufferGuard() immediately before render(<App />) so the guard is active for every Ink session (both interactive and run mode). The --plain / --version / --help / login fast-paths are unaffected.

The entries are never read by nanocoder itself, so draining them is entirely lossless.

Note: the scheduler (source/schedule/runner.ts) already calls performance.clear*() after each job as a per-run clean-up; this PR fixes the Ink/interactive path that had no equivalent.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Testing

Automated Tests

  • All existing tests pass (pnpm test:types passes with no errors)
  • New features include passing tests in .spec.ts/tsx files

Manual Testing

  • Tested with Ollama
  • Tested with OpenRouter
  • Tested with OpenAI-compatible API
  • Tested MCP integration (if applicable)

Checklist

  • Code follows project style guidelines
  • Self-review completed
  • Documentation updated (if needed)
  • No breaking changes (or clearly documented)
  • Appropriate logging added using structured logging (see CONTRIBUTING.md)

…ano-Collective#521)

React 19's react-reconciler (dev build, shipped in node_modules) calls
performance.measure() and performance.mark() on every render to emit
user-timing entries for React DevTools. Node's built-in fetch (undici)
similarly pushes a resource-timing entry per HTTP request. Both write to
the same global performance entry buffer, which is never drained because
nanocoder doesn't run a PerformanceObserver.

Over long workflows with many subagent runs the buffer accumulates
millions of entries. Node logs MaxPerformanceEntryBufferExceededWarning
at the 1M mark, then V8 spends increasing time in mark-compact GC, and
the process eventually crashes:

  FATAL ERROR: Ineffective mark-compacts near heap limit
  Allocation failed - JavaScript heap out of memory

Fix: install an unref'd 30s interval in the Ink render path that cFix: install an unref'd 30s interval in the Ink render path that cFix: install an unref'd 30s interval in the Ink render path that cFix: install an unref'd 30s interval in the Ink render path that cF/utFix: install an unref'd 30s interval in the Ink render path than sourceFix: install an unref'd 30s interval in the Ink render path thative \Fix: install an unref'd 30s interval in the Ink render path that cFix and is unaffected.

Closes Nano-Collective#521
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Prevents long-running Ink (React) terminal sessions from accumulating unbounded Node perf_hooks performance entries (marks/measures/resource timings), which can eventually lead to heap OOM crashes during workflows with many renders/subagent runs.

Changes:

  • Added a periodic “performance entry buffer guard” utility that clears marks/measures (and resource timings when supported).
  • Installed the guard in the Ink CLI path immediately before rendering the app.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
source/utils/perf-buffer.ts New helper that installs an unref()’d interval to clear the global performance entry buffer periodically.
source/cli.tsx Calls installPerfBufferGuard() in the Ink render path to prevent long-session buffer growth.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +23 to +25
* `mark` / `measure` entries. We don't read them anywhere in the app, so this
* is lossless from our perspective, and it caps the buffer to at most one
* interval's worth of entries instead of letting it grow unbounded.
Comment on lines +28 to +36
const DEFAULT_INTERVAL_MS = 30_000;

let installed = false;

export function installPerfBufferGuard(
intervalMs: number = DEFAULT_INTERVAL_MS,
): void {
if (installed) return;
if (
@will-lamerton will-lamerton merged commit a0b3915 into Nano-Collective:main May 19, 2026
12 of 14 checks passed
@will-lamerton
Copy link
Copy Markdown
Member

Great fix - thank you :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JavaScript heap out of memory when using long workflows with subagents

3 participants