Bug: Node.js OOM crash after ~37 minutes — 31,965 leaked async libuv handles
Version: 1.0.82
Platform: Linux x86_64 (Amazon EC2, Node v24.20.0 embedded in SEA)
Observed: 2026-09-01, crash at ~37 min session uptime
Summary
Every session crashes with FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory after roughly 30–60 minutes of use. The crash is caused by a monotonic leak of async libuv handles (~32k by crash time), not by conversation-context growth. /compact and increasing NODE_OPTIONS (which the SEA ignores) do not prevent the crash.
Heap report metrics (from report.20260901.153402.2817455.0.001.json)
| Metric |
Value |
| Session uptime at crash |
2,203 s (~37 min) |
old_space used / capacity |
3,899 MB / 3,899 MB (100% saturated) |
externalMemory |
36 MB |
mallocedMemory |
~1 MB |
memoryLimit |
4,298 MB (~4.0 GB) |
| RSS at crash |
4,634 MB |
GC current mu |
0.173 (thrashing — normally >0.5 is healthy) |
| CPU at crash |
~292% (GC threads saturating 3 cores) |
Growth rate: ~100 MB/min of old_space objects throughout the session.
libuv handle leak
At crash time, the report shows:
libuv handle count: 31,996
type=async, is_active=true, is_referenced=false: 31,965
type=signal, is_active=true: 19
- 31,965 active-but-unreferenced
async handles — every one holds a JS closure/context in old_space. With no detail string (details: ""), these are likely AsyncResource / AsyncLocalStorage contexts that were created but never destroyed.
- 19 active signal handles (vs. the ~17 signals that exist): duplicate SIGABRT, SIGIO registrations suggest repeated
process.on('signal', ...) calls without corresponding removeListener — consistent with code that re-registers handlers per turn or per tool call.
The combination is a textbook listener/async-context leak: each model turn or tool execution allocates one or more async handles that are never cleaned up. After ~32k turns/tool calls the heap saturates.
Why NODE_OPTIONS and heap increases appear to help but don't
The copilot binary is a Node Single Executable Application (SEA) (NODE_SEA_BLOB present; 159 MB on disk). SEAs ignore NODE_OPTIONS entirely — empirically verified: NODE_OPTIONS="--this-is-not-a-real-flag" copilot --version exits 0 with no error.
The commandLine in the crash report confirms only Copilot's own flags are applied:
['copilot','--no-warnings','--report-on-fatalerror','--optimize-for-size','--expose-gc','copilot']
Increasing the (ignored) NODE_OPTIONS heap size is a placebo — the 4 GB cap is always in effect.
Reproduction pattern
The crash is reproducible by running a session of moderate length (>30 min) with regular tool calls (bash, view, grep). The leak appears to be per tool execution or per model turn rather than per session.
The --report-on-fatalerror flag already embedded in the binary's commandLine will produce a report.YYYYMMDD.*.json in the CWD on every crash — these reports contain full diagnostics.
Suggested investigation areas
AsyncResource / AsyncLocalStorage contexts created per turn/tool that escape cleanup (search for new AsyncResource or AsyncLocalStorage usages in app.js)
process.on(signal, ...) calls without process.removeListener — each session re-registration without cleanup would explain the 19 duplicate signal handles
- Any
setInterval/setTimeout created per tool call that is never clearInterval-ed — these register as async handles too
Workaround (until fixed)
Run the Copilot JS dist under a real node with a larger heap:
~/.nvm/versions/node/v24.20.0/bin/node \
--max-old-space-size=12288 \
--expose-gc --no-warnings --optimize-for-size \
~/.cache/copilot/pkg/linux-x64/1.0.82/index.js \
"$@"
This buys ~2 hours before the same leak exhausts 12 GB — same leak, bigger bucket.
Bug: Node.js OOM crash after ~37 minutes — 31,965 leaked async libuv handles
Version: 1.0.82
Platform: Linux x86_64 (Amazon EC2, Node v24.20.0 embedded in SEA)
Observed: 2026-09-01, crash at ~37 min session uptime
Summary
Every session crashes with
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryafter roughly 30–60 minutes of use. The crash is caused by a monotonic leak of async libuv handles (~32k by crash time), not by conversation-context growth./compactand increasingNODE_OPTIONS(which the SEA ignores) do not prevent the crash.Heap report metrics (from
report.20260901.153402.2817455.0.001.json)old_spaceused / capacityexternalMemorymallocedMemorymemoryLimitcurrent muGrowth rate: ~100 MB/min of old_space objects throughout the session.
libuv handle leak
At crash time, the report shows:
asynchandles — every one holds a JS closure/context in old_space. With no detail string (details: ""), these are likelyAsyncResource/AsyncLocalStoragecontexts that were created but never destroyed.process.on('signal', ...)calls without correspondingremoveListener— consistent with code that re-registers handlers per turn or per tool call.The combination is a textbook listener/async-context leak: each model turn or tool execution allocates one or more async handles that are never cleaned up. After ~32k turns/tool calls the heap saturates.
Why
NODE_OPTIONSand heap increases appear to help but don'tThe
copilotbinary is a Node Single Executable Application (SEA) (NODE_SEA_BLOBpresent; 159 MB on disk). SEAs ignoreNODE_OPTIONSentirely — empirically verified:NODE_OPTIONS="--this-is-not-a-real-flag" copilot --versionexits 0 with no error.The commandLine in the crash report confirms only Copilot's own flags are applied:
Increasing the (ignored)
NODE_OPTIONSheap size is a placebo — the 4 GB cap is always in effect.Reproduction pattern
The crash is reproducible by running a session of moderate length (>30 min) with regular tool calls (bash, view, grep). The leak appears to be per tool execution or per model turn rather than per session.
The
--report-on-fatalerrorflag already embedded in the binary's commandLine will produce areport.YYYYMMDD.*.jsonin the CWD on every crash — these reports contain full diagnostics.Suggested investigation areas
AsyncResource/AsyncLocalStoragecontexts created per turn/tool that escape cleanup (search fornew AsyncResourceorAsyncLocalStorageusages inapp.js)process.on(signal, ...)calls withoutprocess.removeListener— each session re-registration without cleanup would explain the 19 duplicate signal handlessetInterval/setTimeoutcreated per tool call that is neverclearInterval-ed — these register as async handles tooWorkaround (until fixed)
Run the Copilot JS dist under a real node with a larger heap:
This buys ~2 hours before the same leak exhausts 12 GB — same leak, bigger bucket.