#978·ink

Native (external) memory leak on repeated render/unmount of a component using useStdin().setRawMode()

Author: stvh8Created Jul 18, 2026Updated Aug 24, 2026

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.1
  • ink-testing-library: (bundled peer, resolved via ink's own devDependency graph)
  • yoga-layout (ink's layout engine, transitive dep): 3.2.1
  • react: 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's cleanup()/instances bookkeeping — we'd already worked around a separate, real O(n²) issue there (its cleanup() 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-size capped as low as 300MB) never fires. heapUsed stays ~15-16MB flat throughout. The growth is entirely in external.
  • Not arrayBuffers — stays at 0.0 throughout 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 call render() 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-op test() calls (no render() at all) does not trigger it — it specifically needs other tests that actually call render().
  • 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/useStdin usage) 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:

bash
node --expose-gc --test test/verbatim-repro.test.mjs

It 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.errorexternal 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.mjs
javascript
import { 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.js
javascript
import 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.js
javascript
import 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.js
javascript
import { 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:

javascript
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:

javascript
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 wri