fix(memory): prevent reported idle retention paths (#1856)

* fix(build): bundle production React in CLI

* fix(memory): bound reported idle retention paths

* fix(memory): address review feedback

* fix(memory): keep fps average stable after sample cap

* test(memory): cover heap dump filenames
This commit is contained in:
JATMN
2026-07-05 13:30:25 +08:00
committed by GitHub
parent 2ac20c759b
commit 354feb483c
7 changed files with 310 additions and 34 deletions
+58 -1
View File
@@ -8,13 +8,69 @@
* - src/ path aliases
*/
import { readFileSync } from 'fs'
import { existsSync, readFileSync } from 'fs'
import { createRequire } from 'module'
import { dirname, join } from 'path'
import { noTelemetryPlugin } from './no-telemetry-plugin'
import { CLI_EXTERNALS, SDK_EXTERNALS } from './externals.js'
import { canonicalStub, collectBundleStubs } from './stubMarkerGuard.js'
const nodeRequire = createRequire(import.meta.url)
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
const version = pkg.version
const reactPackageDir = dirname(nodeRequire.resolve('react/package.json'))
const reactReconcilerPackageDir = dirname(
nodeRequire.resolve('react-reconciler/package.json'),
)
const schedulerPackageDir = dirname(nodeRequire.resolve('scheduler/package.json'))
const productionReactModules = new Map<string, string>([
['react', join(reactPackageDir, 'cjs/react.production.js')],
[
'react/jsx-runtime',
join(reactPackageDir, 'cjs/react-jsx-runtime.production.js'),
],
[
'react/jsx-dev-runtime',
join(reactPackageDir, 'cjs/react-jsx-dev-runtime.production.js'),
],
[
'react-reconciler',
join(reactReconcilerPackageDir, 'cjs/react-reconciler.production.js'),
],
[
'react-reconciler/constants.js',
join(
reactReconcilerPackageDir,
'cjs/react-reconciler-constants.production.js',
),
],
['scheduler', join(schedulerPackageDir, 'cjs/scheduler.production.js')],
])
for (const [specifier, resolvedPath] of productionReactModules) {
if (!existsSync(resolvedPath)) {
throw new Error(
`productionReactPlugin: expected production file for "${specifier}" not found at ${resolvedPath}. ` +
'The installed React package layout may have changed.',
)
}
}
const productionReactPlugin = {
name: 'production-react-bundle',
setup(build) {
build.onResolve(
{
filter:
/^(react|react\/jsx-runtime|react\/jsx-dev-runtime|react-reconciler|react-reconciler\/constants\.js|scheduler)$/,
},
args => {
const path = productionReactModules.get(args.path)
return path ? { path } : null
},
)
},
}
// Feature flags for the open build.
// Most Anthropic-internal features stay off; open-build features can be
@@ -146,6 +202,7 @@ result = await Bun.build({
plugins: [
noTelemetryPlugin,
featureFlagPreprocessPlugin,
productionReactPlugin,
{
name: 'bun-bundle-shim',
setup(build) {
+15
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
appendBoundedMcpStderr,
cleanupFailedConnection,
buildMcpStdioCommand,
logMcpServerStderr,
@@ -107,6 +108,20 @@ test('failed MCP startup stderr remains error-level', () => {
})
})
test('appendBoundedMcpStderr caps retained stderr and marks truncation', () => {
const output = appendBoundedMcpStderr('', Buffer.alloc(300 * 1024, 'x'))
assert.equal(output.length, 256 * 1024)
assert.match(output, /\.\.\.\[stderr truncated\]$/)
})
test('appendBoundedMcpStderr ignores chunks after truncation', () => {
const output = appendBoundedMcpStderr('', Buffer.alloc(300 * 1024, 'x'))
const after = appendBoundedMcpStderr(output, 'more stderr')
assert.equal(after, output)
})
test('buildMcpStdioCommand — no prefix passes command and args through unchanged', () => {
const { command, args } = buildMcpStdioCommand(
'node',
+24 -8
View File
@@ -569,6 +569,29 @@ type InProcessMcpServer = {
close(): Promise<void>
}
const MAX_MCP_STDERR_CHARS = 256 * 1024
const MCP_STDERR_TRUNCATED_MARKER = '\n...[stderr truncated]'
export function appendBoundedMcpStderr(
current: string,
chunk: Buffer | string,
): string {
if (current.includes(MCP_STDERR_TRUNCATED_MARKER)) {
return current
}
const text = typeof chunk === 'string' ? chunk : chunk.toString()
const next = current + text
if (next.length <= MAX_MCP_STDERR_CHARS) {
return next
}
return (
next.slice(0, MAX_MCP_STDERR_CHARS - MCP_STDERR_TRUNCATED_MARKER.length) +
MCP_STDERR_TRUNCATED_MARKER
)
}
export async function cleanupFailedConnection(
transport: Pick<Transport, 'close'>,
inProcessServer?: Pick<InProcessMcpServer, 'close'>,
@@ -1008,14 +1031,7 @@ export const connectToServer = memoize(
const stdioTransport = transport as StdioClientTransport
if (stdioTransport.stderr) {
stderrHandler = (data: Buffer) => {
// Cap stderr accumulation to prevent unbounded memory growth
if (stderrOutput.length < 64 * 1024 * 1024) {
try {
stderrOutput += data.toString()
} catch {
// Ignore errors from exceeding max string length
}
}
stderrOutput = appendBoundedMcpStderr(stderrOutput, data)
}
stdioTransport.stderr.on('data', stderrHandler)
}
+51
View File
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { FpsTracker } from './fpsTracker.js'
test('FpsTracker keeps a bounded frame-duration sample window', () => {
const tracker = new FpsTracker()
for (let i = 0; i < 6000; i++) {
tracker.record(i)
}
const state = tracker as unknown as {
frameDurations: number[]
sampleCount: number
writeIndex: number
}
assert.equal(state.frameDurations.length, 5000)
assert.equal(state.sampleCount, 5000)
assert.equal(state.writeIndex, 1000)
assert.equal(state.frameDurations[state.writeIndex], 1000)
assert.equal(state.frameDurations[state.writeIndex - 1], 5999)
})
test('FpsTracker keeps average FPS stable after the sample cap is reached', () => {
const originalPerformance = globalThis.performance
let now = 0
Object.defineProperty(globalThis, 'performance', {
configurable: true,
value: {
now: () => now,
},
})
try {
const tracker = new FpsTracker()
for (let i = 0; i < 6000; i++) {
now = i * (1000 / 60)
tracker.record(1000 / 60)
}
assert.equal(tracker.getMetrics()?.averageFps, 60.01)
} finally {
Object.defineProperty(globalThis, 'performance', {
configurable: true,
value: originalPerformance,
})
}
})
+24 -5
View File
@@ -3,8 +3,13 @@ export type FpsMetrics = {
low1PctFps: number
}
const MAX_FRAME_DURATION_SAMPLES = 5000
export class FpsTracker {
private frameDurations: number[] = []
private frameDurations: number[] = new Array(MAX_FRAME_DURATION_SAMPLES)
private sampleCount = 0
private totalFrameCount = 0
private writeIndex = 0
private firstRenderTime: number | undefined
private lastRenderTime: number | undefined
@@ -14,12 +19,15 @@ export class FpsTracker {
this.firstRenderTime = now
}
this.lastRenderTime = now
this.frameDurations.push(durationMs)
this.totalFrameCount += 1
this.frameDurations[this.writeIndex] = durationMs
this.writeIndex = (this.writeIndex + 1) % MAX_FRAME_DURATION_SAMPLES
this.sampleCount = Math.min(this.sampleCount + 1, MAX_FRAME_DURATION_SAMPLES)
}
getMetrics(): FpsMetrics | undefined {
if (
this.frameDurations.length === 0 ||
this.sampleCount === 0 ||
this.firstRenderTime === undefined ||
this.lastRenderTime === undefined
) {
@@ -31,10 +39,10 @@ export class FpsTracker {
return undefined
}
const totalFrames = this.frameDurations.length
const totalFrames = this.totalFrameCount
const averageFps = totalFrames / (totalTimeMs / 1000)
const sorted = this.frameDurations.slice().sort((a, b) => b - a)
const sorted = this.getSamples().sort((a, b) => b - a)
const p99Index = Math.max(0, Math.ceil(sorted.length * 0.01) - 1)
const p99FrameTimeMs = sorted[p99Index]!
const low1PctFps = p99FrameTimeMs > 0 ? 1000 / p99FrameTimeMs : 0
@@ -44,4 +52,15 @@ export class FpsTracker {
low1PctFps: Math.round(low1PctFps * 100) / 100,
}
}
private getSamples(): number[] {
if (this.sampleCount < MAX_FRAME_DURATION_SAMPLES) {
return this.frameDurations.slice(0, this.sampleCount)
}
return [
...this.frameDurations.slice(this.writeIndex),
...this.frameDurations.slice(0, this.writeIndex),
]
}
}
+66
View File
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
__resetManualHeapDumpCountForTesting,
getHeapDumpAnalyticsMetadata,
getEffectiveHeapDumpNumber,
getHeapDumpFilePaths,
} from './heapDumpService.js'
test('manual heap dumps receive sequential effective dump numbers', () => {
__resetManualHeapDumpCountForTesting()
assert.equal(getEffectiveHeapDumpNumber('manual'), 1)
assert.equal(getEffectiveHeapDumpNumber('manual'), 2)
})
test('explicit and auto heap dump numbers pass through unchanged', () => {
__resetManualHeapDumpCountForTesting()
assert.equal(getEffectiveHeapDumpNumber('manual', 7), 7)
assert.equal(getEffectiveHeapDumpNumber('auto-1.5GB', 3), 3)
assert.equal(getEffectiveHeapDumpNumber('auto-1.5GB'), 0)
})
test('failure analytics uses the effective heap dump number', () => {
__resetManualHeapDumpCountForTesting()
const effectiveDumpNumber = getEffectiveHeapDumpNumber('manual')
assert.deepEqual(
getHeapDumpAnalyticsMetadata('manual', effectiveDumpNumber, false),
{
triggerManual: true,
triggerAuto15GB: false,
dumpNumber: 1,
success: false,
},
)
})
test('effective dump numbers are used in generated heap dump filenames', () => {
__resetManualHeapDumpCountForTesting()
const firstManual = getEffectiveHeapDumpNumber('manual')
const secondManual = getEffectiveHeapDumpNumber('manual')
assert.deepEqual(
getHeapDumpFilePaths('session-123', '/tmp/dumps', firstManual),
{
heapPath: '/tmp/dumps/session-123-dump1.heapsnapshot',
diagPath: '/tmp/dumps/session-123-dump1-diagnostics.json',
},
)
assert.deepEqual(
getHeapDumpFilePaths('session-123', '/tmp/dumps', secondManual),
{
heapPath: '/tmp/dumps/session-123-dump2.heapsnapshot',
diagPath: '/tmp/dumps/session-123-dump2-diagnostics.json',
},
)
assert.deepEqual(getHeapDumpFilePaths('session-123', '/tmp/dumps', 7), {
heapPath: '/tmp/dumps/session-123-dump7.heapsnapshot',
diagPath: '/tmp/dumps/session-123-dump7-diagnostics.json',
})
})
+72 -20
View File
@@ -29,6 +29,58 @@ export type HeapDumpResult = {
error?: string
}
let manualHeapDumpCount = 0
export function __resetManualHeapDumpCountForTesting(): void {
manualHeapDumpCount = 0
}
export function getEffectiveHeapDumpNumber(
trigger: 'manual' | 'auto-1.5GB',
dumpNumber = 0,
): number {
if (dumpNumber > 0) {
return dumpNumber
}
if (trigger === 'manual') {
return ++manualHeapDumpCount
}
return dumpNumber
}
export function getHeapDumpAnalyticsMetadata(
trigger: 'manual' | 'auto-1.5GB',
effectiveDumpNumber: number,
success: boolean,
): {
triggerManual: boolean
triggerAuto15GB: boolean
dumpNumber: number
success: boolean
} {
return {
triggerManual: trigger === 'manual',
triggerAuto15GB: trigger === 'auto-1.5GB',
dumpNumber: effectiveDumpNumber,
success,
}
}
export function getHeapDumpFilePaths(
sessionId: string,
dumpDir: string,
effectiveDumpNumber: number,
): { heapPath: string; diagPath: string } {
const suffix =
effectiveDumpNumber > 0 ? `-dump${effectiveDumpNumber}` : ''
return {
heapPath: join(dumpDir, `${sessionId}${suffix}.heapsnapshot`),
diagPath: join(dumpDir, `${sessionId}${suffix}-diagnostics.json`),
}
}
/**
* Memory diagnostics captured alongside heap dump.
* Helps identify if leak is in V8 heap (captured in snapshot) or native memory (not captured).
@@ -37,7 +89,7 @@ export type MemoryDiagnostics = {
timestamp: string
sessionId: string
trigger: 'manual' | 'auto-1.5GB'
dumpNumber: number // 1st, 2nd, etc. auto dump in this session (0 for manual)
dumpNumber: number // 1st, 2nd, etc. dump in this session
uptimeSeconds: number
memoryUsage: {
heapUsed: number
@@ -222,12 +274,17 @@ export async function performHeapDump(
trigger: 'manual' | 'auto-1.5GB' = 'manual',
dumpNumber = 0,
): Promise<HeapDumpResult> {
let effectiveDumpNumber = dumpNumber
try {
const sessionId = getSessionId()
effectiveDumpNumber = getEffectiveHeapDumpNumber(trigger, dumpNumber)
// Capture diagnostics before any other async I/O —
// the heap dump itself allocates memory and would skew the numbers.
const diagnostics = await captureMemoryDiagnostics(trigger, dumpNumber)
const diagnostics = await captureMemoryDiagnostics(
trigger,
effectiveDumpNumber,
)
const toGB = (bytes: number): string =>
(bytes / 1024 / 1024 / 1024).toFixed(3)
@@ -239,12 +296,11 @@ export async function performHeapDump(
const dumpDir = getDesktopPath()
await getFsImplementation().mkdir(dumpDir)
const suffix = dumpNumber > 0 ? `-dump${dumpNumber}` : ''
const heapFilename = `${sessionId}${suffix}.heapsnapshot`
const diagFilename = `${sessionId}${suffix}-diagnostics.json`
const heapPath = join(dumpDir, heapFilename)
const diagPath = join(dumpDir, diagFilename)
const { heapPath, diagPath } = getHeapDumpFilePaths(
sessionId,
dumpDir,
effectiveDumpNumber,
)
// Write diagnostics first (cheap, unlikely to fail)
await writeFile(diagPath, jsonStringify(diagnostics, null, 2), {
@@ -256,23 +312,19 @@ export async function performHeapDump(
await writeHeapSnapshot(heapPath)
logForDebugging(`[HeapDump] Heap dump written to ${heapPath}`)
logEvent('tengu_heap_dump', {
triggerManual: trigger === 'manual',
triggerAuto15GB: trigger === 'auto-1.5GB',
dumpNumber,
success: true,
})
logEvent(
'tengu_heap_dump',
getHeapDumpAnalyticsMetadata(trigger, effectiveDumpNumber, true),
)
return { success: true, heapPath, diagPath }
} catch (err) {
const error = toError(err)
logError(error)
logEvent('tengu_heap_dump', {
triggerManual: trigger === 'manual',
triggerAuto15GB: trigger === 'auto-1.5GB',
dumpNumber,
success: false,
})
logEvent(
'tengu_heap_dump',
getHeapDumpAnalyticsMetadata(trigger, effectiveDumpNumber, false),
)
return { success: false, error: error.message }
}
}