Native (external) memory leak on repeated render/unmount of a component using useStdin().setRawMode()
Summary
Repeatedly rendering/unmounting a component that toggles useStdin().setRawMode() around an async yield (a common "hand off the terminal to a child process, then reclaim it" pattern) causes native (external) memory to grow substantially per render/unmount cycle and never be reclaimed — even by an explicit global.gc(). heapUsed and arrayBuffers stay essentially flat; only external climbs. Left running (e.g. under node --test), this reliably grows into gigabytes within a couple dozen render cycles and gets OOM-killed.
This surfaced as recurring OOM kills of our test suite (and, worse, of the terminal process running it, since it shared a memory-capped cgroup — a self-inflicted problem on our end, not part of this report). I've root-caused it down to external memory specifically, which points at a native/off-heap leak rather than a JS-level reference leak — my best guess is Yoga's WASM layout tree not being fully freed on unmount, but I haven't been able to prove that specific mechanism and want to report the evidence rather than over-claim.
Environment
ink: 7.1.1ink-testing-library: (bundled peer, resolved viaink's own devDependency graph)yoga-layout(ink's layout engine, transitive dep): 3.2.1react: peer dep of ink- Node.js: v24.15.0
- OS: Ubuntu 24.04.4 LTS
What I ruled out
Before landing on this, I bisected away several other explanations — noting these since they'll save whoever investigates this some time:
- Not
ink-testing-library'scleanup()/instancesbookkeeping — we'd already worked around a separate, real O(n²) issue there (itscleanup()re-unmounts every prior instance on every call) by tracking and unmounting only each test's own instance. That fix is in place in the repro below and the leak persists regardless. - Not a JS-level reference leak —
--heapsnapshot-near-heap-limit(even with--max-old-space-sizecapped as low as 300MB) never fires.heapUsedstays ~15-16MB flat throughout. The growth is entirely inexternal. - Not
arrayBuffers— stays at0.0throughout in every run where the leak reproduces. - Not simply "many
render()calls accumulate" — a byte-identical minimal file with only the 3 leaking tests (no other tests in the file) stays completely flat (~95-100MB) no matter how many times it's run. It only manifests when those 3 tests run in the same process alongside ~7-10+ other tests that also callrender()on unrelated components — even though those other tests are registered before the leaking ones and have nothing to do with raw mode. Padding a minimal file with 40 trivial no-optest()calls (norender()at all) does not trigger it — it specifically needs other tests that actually callrender(). - Not reproducible with simplified/rewritten stand-in components — I tried building a fully synthetic, generic version of this shape (invented components with similar
Box/Text/useInput/useStdinusage) twice, and neither one leaked. Only the exact real component source below reproduces it reliably. I don't know what property of the real components is load-bearing here (props shape? render tree depth? something in the otherwise-unused imports?) — flagging this because it may be a useful clue for someone who knows Ink's/Yoga's internals better than I do.
Reproduction
npm install [email protected] ink-testing-library react into an empty directory, lay out the files below, then:
node --expose-gc --test test/verbatim-repro.test.mjsIt will OOM (or, uncapped, climb into several GB of RSS) partway through the RunDetail pressing "a" tests. Watch the [mem] lines it prints via console.error — external roughly doubles after just the second of those three tests.
Sample output from a run capped at 3GB (systemd-run --scope -p MemoryMax=3G):
[mem] "RunDetail pressing "a" on a run with live set calls att" rss=91.7 heapUsed=15.6 external=19.8 arrayBuffers=0.0
[mem] "RunDetail pressing "a" resets the terminal (screen repa" rss=106.1 heapUsed=15.9 external=35.6 arrayBuffers=3.6
<OOM-killed partway through the third test>(external/arrayBuffers figures read after an explicit global.gc() call, so this isn't just uncollected garbage.)
Files
test/verbatim-repro.test.mjsimport { test, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import React from 'react'
import { render as inkRender } from 'ink-testing-library'
import { RunList } from '../src/components/RunList.js'
import { RunDetail } from '../src/components/RunDetail.js'
const h = React.createElement
let liveInstances = []
function render(tree) {
const result = inkRender(tree)
liveInstances.push(result)
return result
}
afterEach((t) => {
liveInstances.forEach((instance) => instance.unmount())
liveInstances = []
if (typeof global.gc === 'function') global.gc()
const m = process.memoryUsage()
const fmt = (b) => (b / 1024 / 1024).toFixed(1)
console.error(
` [mem] "${t?.name?.slice(0, 55)}" rss=${fmt(m.rss)} heapUsed=${fmt(m.heapUsed)} external=${fmt(m.external)} arrayBuffers=${fmt(m.arrayBuffers)}`
)
})
test('RunList renders a placeholder when there are no runs', () => {
const { lastFrame } = render(h(RunList, { runs: [], onSelect: () => {} }))
assert.match(lastFrame(), /No darkly runs found yet/)
})
test('RunList renders each run\'s repo, label, and status', () => {
const runs = [
{ id: 'a', repoRoot: '/home/x/ai-trading-platform', featureDir: '/home/x/ai-trading-platform/specs/011-y', label: '011-y', phase: 'Setup', isHalted: false, haltStatus: null, live: { kind: 'local', state: 'busy' }, latestBenchmark: null },
]
const { lastFrame } = render(h(RunList, { runs, onSelect: () => {} }))
const frame = lastFrame()
assert.match(frame, /ai-trading-platform/)
assert.match(frame, /011-y/)
})
test('RunList shows a red halt status for a halted run', () => {
const runs = [
{ id: 'a', repoRoot: '/repo', featureDir: '/repo/specs/001-x', label: '001-x', phase: null, isHalted: true, haltStatus: 'PERMISSION_NEEDED', live: null, latestBenchmark: null },
]
const { lastFrame } = render(h(RunList, { runs, onSelect: () => {} }))
assert.match(lastFrame(), /PERMISSION_NEEDED/)
})
test('RunList keyboard navigation: down arrow moves cursor and Enter calls onSelect with correct run id', async () => {
const selectedIds = []
const onSelect = (id) => selectedIds.push(id)
const runs = [
{ id: 'first-run', repoRoot: '/repo1', featureDir: '/repo1/specs/001', label: '001', phase: 'Setup', isHalted: false, haltStatus: null, live: { kind: 'local', state: 'idle' }, latestBenchmark: null },
{ id: 'second-run', repoRoot: '/repo2', featureDir: '/repo2/specs/002', label: '002', phase: 'Setup', isHalted: false, haltStatus: null, live: { kind: 'local', state: 'idle' }, latestBenchmark: null },
]
const { stdin } = render(h(RunList, { runs, onSelect }))
stdin.write('\x1b[B')
await new Promise((resolve) => setTimeout(resolve, 10))
stdin.write('\r')
await new Promise((resolve) => setImmediate(resolve))
assert.deepEqual(selectedIds, ['second-run'])
})
test('RunList shows SUCCESS status when benchmark has SUCCESS status', () => {
const runs = [
{ id: 'a', repoRoot: '/repo', featureDir: '/repo/specs/001-x', label: '001-x', phase: null, isHalted: false, haltStatus: null, live: null, latestBenchmark: { status: 'SUCCESS' } },
]
const { lastFrame } = render(h(RunList, { runs, onSelect: () => {} }))
assert.match(lastFrame(), /SUCCESS/)
})
test('RunList shows unknown status when run has no halt, no live state, and no benchmark', () => {
const runs = [
{ id: 'a', repoRoot: '/repo', featureDir: '/repo/specs/001-x', label: '001-x', phase: null, isHalted: false, haltStatus: null, live: null, latestBenchmark: null },
]
const { lastFrame } = render(h(RunList, { runs, onSelect: () => {} }))
assert.match(lastFrame(), /unknown/)
})
test('RunList shows a spinner when refreshing is true, and hides it when false', () => {
const notRefreshing = render(h(RunList, { runs: [], onSelect: () => {}, refreshing: false }))
assert.doesNotMatch(notRefreshing.lastFrame(), /[⠀-⣿]/)
const refreshing = render(h(RunList, { runs: [], onSelect: () => {}, refreshing: true }))
assert.match(refreshing.lastFrame(), /[⠀-⣿]/)
})
process.env.DARKLY_SLACK_BOT_TOKEN = process.env.DARKLY_SLACK_BOT_TOKEN || 'test-token'
test('RunDetail pressing "a" on a run with live set calls attachFn and onRefresh', async () => {
const run = { id: 'a', runDir: '/tmp/rundir', repoRoot: '/repo', featureDir: '/repo/specs/001-x', label: '001-x', phase: 'User Story 1', isHalted: false, haltStatus: null, lastLogLine: null, live: { kind: 'local', sessionId: 'sess-123', state: 'idle' } }
const attachCalls = []
let refreshCount = 0
const { stdin } = render(
h(RunDetail, { run, onBack: () => {}, onRefresh: () => { refreshCount += 1 }, attachFn: (attachArgs) => { attachCalls.push(attachArgs) } })
)
stdin.write('a')
await new Promise((resolve) => setImmediate(resolve))
await new Promise((resolve) => setImmediate(resolve))
assert.equal(attachCalls.length, 1)
assert.equal(refreshCount, 1)
})
test('RunDetail pressing "a" resets the terminal (screen repaint) around attachFn, then refreshes', async () => {
const run = { id: 'a', runDir: '/tmp/rundir', repoRoot: '/repo', featureDir: '/repo/specs/001-x', label: '001-x', phase: 'User Story 1', isHalted: false, haltStatus: null, lastLogLine: null, live: { kind: 'local', sessionId: 'sess-123', state: 'idle' } }
const events = []
const originalWrite = process.stdout.write
process.stdout.write = (chunk) => { events.push(['write', chunk]); return true }
let stdin
try {
;({ stdin } = render(
h(RunDetail, { run, onBack: () => {}, onRefresh: () => events.push(['onRefresh']), attachFn: (attachArgs) => events.push(['attach', attachArgs]) })
))
stdin.write('a')
await new Promise((resolve) => setImmediate(resolve))
await new Promise((resolve) => setImmediate(resolve))
} finally {
process.stdout.write = originalWrite
}
})
test('RunDetail pressing "a" does NOT call attachFn synchronously — it genuinely yields to the event loop first', async () => {
const run = { id: 'a', runDir: '/tmp/rundir', repoRoot: '/repo', featureDir: '/repo/specs/001-x', label: '001-x', phase: 'User Story 1', isHalted: false, haltStatus: null, lastLogLine: null, live: { kind: 'local', sessionId: 'sess-123', state: 'idle' } }
const events = []
const originalWrite = process.stdout.write
process.stdout.write = (chunk) => { events.push(['write', chunk]); return true }
let stdin
try {
;({ stdin } = render(
h(RunDetail, { run, onBack: () => {}, onRefresh: () => events.push(['onRefresh']), attachFn: (attachArgs) => events.push(['attach', attachArgs]) })
))
stdin.write('a')
assert.deepEqual(events, [])
await new Promise((resolve) => setImmediate(resolve))
await new Promise((resolve) => setImmediate(resolve))
} finally {
process.stdout.write = originalWrite
}
})src/components/RunList.jsimport React, { useState } from 'react'
import { Box, Text, useInput } from 'ink'
const h = React.createElement
function statusLabel(run) {
if (run.isHalted) return { text: run.haltStatus, color: 'red' }
if (run.live && run.live.state) return { text: `running (${run.live.state})`, color: 'green' }
if (run.latestBenchmark && run.latestBenchmark.status === 'SUCCESS') return { text: 'SUCCESS', color: 'green' }
return { text: 'unknown', color: 'gray' }
}
export function RunList({ runs, onSelect }) {
const [index, setIndex] = useState(0)
useInput((_input, key) => {
if (runs.length === 0) return
if (key.upArrow) setIndex((i) => Math.max(0, i - 1))
if (key.downArrow) setIndex((i) => Math.min(runs.length - 1, i + 1))
if (key.return && runs[index]) onSelect(runs[index].id)
})
return h(
Box,
{ flexDirection: 'column' },
h(Text, { bold: true }, 'darkly-console — Run List'),
h(Text, { dimColor: true }, '↑↓ select · Enter detail · n new run · c config · g ledgers · q quit'),
h(
Box,
{ flexDirection: 'column', marginTop: 1 },
runs.length === 0
? h(Text, { dimColor: true }, 'No darkly runs found yet.')
: runs.map((run, i) => {
const status = statusLabel(run)
const repoLabel = run.repoRoot ? run.repoRoot.split('/').pop() : run.id
return h(
Box,
{ key: run.id },
h(Text, { inverse: i === index }, `${repoLabel} ${run.label} `),
h(Text, { color: status.color }, status.text),
run.phase ? h(Text, { dimColor: true }, ` (${run.phase})`) : null
)
})
)
)
}src/components/RunDetail.jsimport React, { useState } from 'react'
import { Box, Text, useInput, useStdin } from 'ink'
import { readThreadTs, resolveChannelId, fetchHaltMessage, postHaltReply } from '../lib/slackHalt.js'
import { readDarklyConfig } from '../lib/config.js'
import { attach, buildLocalAttachArgs, buildCodespaceAttachArgs } from '../lib/terminalAttach.js'
const h = React.createElement
export function RunDetail({
run,
onBack,
onRefresh,
readThreadTsFn = readThreadTs,
resolveChannelIdFn = resolveChannelId,
fetchHaltMessageFn = fetchHaltMessage,
postHaltReplyFn = postHaltReply,
readDarklyConfigFn = readDarklyConfig,
attachFn = attach,
}) {
const [mode, setMode] = useState('view')
const [haltMessage, setHaltMessage] = useState(null)
const [replyText, setReplyText] = useState('')
const [status, setStatus] = useState(null)
const { stdin, setRawMode, isRawModeSupported } = useStdin()
async function loadHaltMessage() {
try {
const threadTs = readThreadTsFn(run.runDir)
const darklyConfig = readDarklyConfigFn()
const token = process.env.DARKLY_SLACK_BOT_TOKEN
if (!threadTs || !darklyConfig.slackChannel || !token) {
setHaltMessage('(Slack not configured or no thread recorded for this run)')
return
}
const channel = await resolveChannelIdFn({ channel: darklyConfig.slackChannel, token })
const result = await fetchHaltMessageFn({ channel, token, threadTs })
setHaltMessage(result.found ? result.text : '(could not fetch the halt message)')
} catch (err) {
setHaltMessage('(error fetching halt message: ' + err.message + ')')
}
}
async function submitReply() {
try {
const threadTs = readThreadTsFn(run.runDir)
const darklyConfig = readDarklyConfigFn()
const token = process.env.DARKLY_SLACK_BOT_TOKEN
const channel = await resolveChannelIdFn({ channel: darklyConfig.slackChannel, token })
const result = await postHaltReplyFn({ channel, token, threadTs, text: replyText })
setStatus(result.posted ? 'Reply posted.' : `Failed: ${result.notes}`)
setMode('view')
setReplyText('')
} catch (err) {
setStatus('Failed: ' + err.message)
setMode('view')
setReplyText('')
}
}
useInput(async (input, key) => {
if (mode === 'view') {
if (key.escape) onBack()
else if (input === 'a' && run.live) {
const attachArgs =
run.live.kind === 'local'
? buildLocalAttachArgs({ sessionId: run.live.sessionId })
: buildCodespaceAttachArgs({ codespaceName: run.live.name })
if (isRawModeSupported) setRawMode(false)
await new Promise((resolve) => setImmediate(resolve))
attachFn(attachArgs)
process.stdout.write('\x1Bc')
if (isRawModeSupported) setRawMode(true)
onRefresh()
} else if (input === 'r' && run.isHalted) {
setMode('replying')
loadHaltMessage()
}
} else if (mode === 'replying') {
if (key.escape) setMode('view')
else if (key.return) submitReply()
else if (key.backspace || key.delete) setReplyText((t) => t.slice(0, -1))
else if (input) setReplyText((t) => t + input)
}
})
return h(
Box,
{ flexDirection: 'column' },
h(Text, { bold: true }, `${run.label} — ${run.repoRoot || run.id}`),
h(Text, { dimColor: true }, 'a attach terminal · r reply to halt · Esc back'),
h(
Box,
{ marginTop: 1, flexDirection: 'column' },
h(Text, null, `Phase: ${run.phase || '(unknown)'}`),
h(Text, { color: run.isHalted ? 'red' : 'white' }, `Status: ${run.isHalted ? run.haltStatus : 'not halted'}`),
run.lastLogLine ? h(Text, { dimColor: true }, `Last log line: ${run.lastLogLine}`) : null
),
mode === 'replying'
? h(
Box,
{ marginTop: 1, flexDirection: 'column' },
h(Text, { bold: true }, 'Halt message:'),
h(Text, null, haltMessage || 'Loading...'),
h(Text, { bold: true }, 'Your reply (Enter to send, Esc to cancel):'),
h(Text, null, replyText)
)
: status
? h(Text, { color: 'yellow' }, status)
: null
)
}src/lib/terminalAttach.jsimport { spawnSync } from 'node:child_process'
export function buildLocalAttachArgs({ sessionId }) {
return { command: 'claude', args: ['--resume', sessionId] }
}
export function buildCodespaceAttachArgs({ codespaceName }) {
return {
command: 'gh',
args: ['codespace', 'ssh', '-c', codespaceName, '--', 'tmux', 'attach', '-t', 'darkly-cs'],
}
}
export function attach(attachArgs, { spawnSyncFn = spawnSync } = {}) {
const result = spawnSyncFn(attachArgs.command, attachArgs.args, { stdio: 'inherit' })
return result.status
}src/lib/slackHalt.js, src/lib/config.js, src/lib/paths.js (unused by the repro's tests — attachFn/*Fn deps are always injected as mocks — but imported by RunDetail.js at module scope, so needed for it to load)src/lib/paths.js:
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs'
export const DARKLY_HOME = path.join(os.homedir(), '.claude', 'skills', 'darkly')
export const DARKLY_CS_HOME = path.join(os.homedir(), '.claude', 'skills', 'darkly-cs')
export const SLACKR_HOME = path.join(os.homedir(), '.claude', 'skills', 'slackr')
export const DARKLY_CONSOLE_HOME = path.join(os.homedir(), '.claude', 'skills', 'darkly-console')
export function sanitizeFeatureDir(featureDir) {
return featureDir.split(path.sep).join('-')
}
export function runDirFor(featureDir, darklyHome = DARKLY_HOME) {
return path.join(darklyHome, 'runs', sanitizeFeatureDir(featureDir))
}
export function listRunDirs(darklyHome = DARKLY_HOME) {
const runsRoot = path.join(darklyHome, 'runs')
if (!fs.existsSync(runsRoot)) return []
return fs
.readdirSync(runsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => ({ sanitizedName: entry.name, runDir: path.join(runsRoot, entry.name) }))
}src/lib/config.js:
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import { DARKLY_HOME, DARKLY_CS_HOME, DARKLY_CONSOLE_HOME } from './paths.js'
function readJsonFile(filePath, fallback) {
if (!fs.existsSync(filePath)) return fallback
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
} catch {
return fallback
}
}
function writeJsonFile(filePath, data) {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n')
}
export function readDarklyConfig(darklyHome = DARKLY_HOME) {
return readJsonFile(path.join(darklyHome, 'config.json'), { slackChannel: null })
}
export function wriSource: vadimdemedes/ink