diff --git a/packages/@ant/ink/src/components/App.tsx b/packages/@ant/ink/src/components/App.tsx index 1ba33fdd7..bda0c5609 100644 --- a/packages/@ant/ink/src/components/App.tsx +++ b/packages/@ant/ink/src/components/App.tsx @@ -131,8 +131,13 @@ type Props = { const MULTI_CLICK_TIMEOUT_MS = 500; const MULTI_CLICK_DISTANCE = 1; +type ErrorInfo = { + readonly message: string; + readonly stack?: string; +}; + type State = { - readonly error?: Error; + readonly error?: ErrorInfo; }; // Root component for all Ink apps @@ -142,7 +147,7 @@ export default class App extends PureComponent { static displayName = 'InternalApp'; static getDerivedStateFromError(error: Error) { - return { error }; + return { error: { message: error.message, stack: error.stack } }; } override state = { @@ -221,7 +226,7 @@ export default class App extends PureComponent { {})}> - {this.state.error ? : this.props.children} + {this.state.error ? : this.props.children} diff --git a/packages/@ant/ink/src/components/ErrorOverview.tsx b/packages/@ant/ink/src/components/ErrorOverview.tsx index 5f215536b..29a4dfff7 100644 --- a/packages/@ant/ink/src/components/ErrorOverview.tsx +++ b/packages/@ant/ink/src/components/ErrorOverview.tsx @@ -23,8 +23,13 @@ function getStackUtils(): StackUtils { /* eslint-enable custom-rules/no-process-cwd */ +type ErrorLike = { + readonly message: string; + readonly stack?: string; +}; + type Props = { - readonly error: Error; + readonly error: ErrorLike; }; export default function ErrorOverview({ error }: Props) { diff --git a/src/query.ts b/src/query.ts index a53d238cb..767ba998f 100644 --- a/src/query.ts +++ b/src/query.ts @@ -340,6 +340,15 @@ export async function* query( terminal?.reason === 'aborted_tools' endTrace(langfuseTrace, undefined, isAborted ? 'interrupted' : undefined) } + + // Break the closure chain: toolUseContext captures langfuseTrace which + // holds SpanImpl → otperformance (the 571MB Performance object). Nulling + // these after endTrace allows GC to reclaim the span tree. + if (paramsWithTrace !== params) { + paramsWithTrace.toolUseContext.langfuseTrace = null + paramsWithTrace.toolUseContext.langfuseRootTrace = null + paramsWithTrace.toolUseContext.langfuseBatchSpan = null + } } // Only reached if queryLoop returned normally. Skipped on throw (error diff --git a/src/utils/profilerBase.ts b/src/utils/profilerBase.ts index f70b441d1..83304457e 100644 --- a/src/utils/profilerBase.ts +++ b/src/utils/profilerBase.ts @@ -1,22 +1,69 @@ /** * Shared infrastructure for profiler modules (startupProfiler, queryProfiler, - * headlessProfiler). All three use the same perf_hooks timeline and the same - * line format for detailed reports. + * headlessProfiler). + * + * Uses process.hrtime.bigint() for timing instead of perf_hooks.performance + * to avoid a Bun/JSC memory leak: JSC's Performance object stores marks in a + * C++ Vector that never shrinks even after clearMarks(). Long-running sessions + * (daemon, /loop) accumulate hundreds of MB of dead capacity. + * + * The LightweightPerf class provides the same interface the profilers need + * (mark, getEntriesByType, clearMarks, now) backed by a plain JS Map. */ -import type { performance as PerformanceType } from 'perf_hooks' import { formatFileSize } from './format.js' -// Lazy-load performance API only when profiling is enabled. -// Shared across all profilers — perf_hooks.performance is a process-wide singleton. -let performance: typeof PerformanceType | null = null +/** Minimal PerformanceEntry-like object used by profilers */ +export interface CheckpointEntry { + readonly name: string + readonly startTime: number + readonly entryType: 'mark' +} -export function getPerformance(): typeof PerformanceType { - if (!performance) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - performance = require('perf_hooks').performance +/** + * Lightweight replacement for perf_hooks.performance that stores marks in a + * plain JavaScript Map instead of JSC's C++ Vector. This avoids the memory + * leak where clearMarks() sets the count to 0 but never frees Vector capacity. + */ +class LightweightPerf { + private marks = new Map() + private _origin: number + + constructor() { + this._origin = Number(process.hrtime.bigint() / 1000n) / 1000 } - return performance! + + mark(name: string): void { + this.marks.set(name, this.now()) + } + + getEntriesByType(type: 'mark'): CheckpointEntry[] { + if (type !== 'mark') return [] + const entries: CheckpointEntry[] = [] + for (const [name, startTime] of this.marks) { + entries.push({ name, startTime, entryType: 'mark' }) + } + return entries + } + + clearMarks(name?: string): void { + if (name !== undefined) { + this.marks.delete(name) + } else { + this.marks.clear() + } + } + + now(): number { + return Number(process.hrtime.bigint() / 1000n) / 1000 - this._origin + } +} + +// Singleton — shared across all profilers (same as the old perf_hooks singleton) +const perf = new LightweightPerf() + +export function getPerformance(): LightweightPerf { + return perf } export function formatMs(ms: number): string {