mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
+58
-1
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user