diff --git a/build.ts b/build.ts index 4854fd51c..6cb8e73ec 100644 --- a/build.ts +++ b/build.ts @@ -21,7 +21,13 @@ const result = await Bun.build({ outdir, target: 'bun', splitting: true, - define: getMacroDefines(), + define: { + ...getMacroDefines(), + // React production mode — eliminates _debugStack Error objects + // (6,889 objects × ~1.7KB = 12MB in development builds) and removes + // prop-type / key warnings not useful in a production CLI tool. + 'process.env.NODE_ENV': JSON.stringify('production'), + }, features, }) diff --git a/scripts/dev.ts b/scripts/dev.ts index 452cf7ea2..72919caa8 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -14,7 +14,12 @@ const __dirname = dirname(__filename) const projectRoot = join(__dirname, '..') const cliPath = join(projectRoot, 'src/entrypoints/cli.tsx') -const defines = getMacroDefines() +const defines = { + ...getMacroDefines(), + // React production mode — prevents 6,889+ _debugStack Error objects + // (12MB) from accumulating during long-running sessions. + 'process.env.NODE_ENV': JSON.stringify('production'), +} const defineArgs = Object.entries(defines).flatMap(([k, v]) => [ '-d', diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 7e4da4ed0..efe7e5272 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -1,4 +1,8 @@ #!/usr/bin/env bun +// Performance shim MUST be the first import — it replaces globalThis.performance +// with a JS-backed implementation before React/OTel capture the native reference. +// Without this, JSC's C++ Vector grows without bound in long-running sessions. +import '../utils/performanceShim.js'; import { feature } from 'bun:bundle'; import { isEnvTruthy } from '../utils/envUtils.js'; diff --git a/src/query.ts b/src/query.ts index 767ba998f..cb9c0e1a5 100644 --- a/src/query.ts +++ b/src/query.ts @@ -124,6 +124,7 @@ import { count } from './utils/array.js' import { createTrace, endTrace, + flushLangfuse, isLangfuseEnabled, } from './services/langfuse/index.js' import { getAPIProvider } from './utils/model/providers.js' @@ -339,6 +340,11 @@ export async function* query( terminal?.reason === 'aborted_streaming' || terminal?.reason === 'aborted_tools' endTrace(langfuseTrace, undefined, isAborted ? 'interrupted' : undefined) + // Flush the processor to release span data (including serialized + // conversation history stored as langfuse.observation.input). Without + // this, SpanImpl objects retain hundreds of KB of JSON until the + // processor's batch timer fires (default 10s). + await flushLangfuse() } // Break the closure chain: toolUseContext captures langfuseTrace which @@ -349,6 +355,21 @@ export async function* query( paramsWithTrace.toolUseContext.langfuseRootTrace = null paramsWithTrace.toolUseContext.langfuseBatchSpan = null } + + // Clear JSC's native Performance buffers. OTel (otperformance) references + // globalThis.performance which stores marks/measures/resource timings in a + // C++ Vector that never shrinks. Long-running sessions accumulate hundreds + // of MB of dead capacity even after spans are flushed and nullified. + const gPerf = globalThis.performance + if (gPerf && typeof gPerf.clearMarks === 'function') { + try { + gPerf.clearMarks() + gPerf.clearMeasures?.() + gPerf.clearResourceTimings?.() + } catch { + // Non-critical — some environments may not support all methods + } + } } // Only reached if queryLoop returned normally. Skipped on throw (error diff --git a/src/services/langfuse/client.ts b/src/services/langfuse/client.ts index 256cd8af8..7fd50f2ed 100644 --- a/src/services/langfuse/client.ts +++ b/src/services/langfuse/client.ts @@ -61,6 +61,16 @@ export function initLangfuse(): boolean { } } +export async function flushLangfuse(): Promise { + try { + if (processor) { + await processor.forceFlush() + } + } catch (e) { + logForDebugging(`[langfuse] Flush error: ${e}`, { level: 'error' }) + } +} + export async function shutdownLangfuse(): Promise { try { if (processor) { diff --git a/src/services/langfuse/index.ts b/src/services/langfuse/index.ts index b533bec69..d52088a26 100644 --- a/src/services/langfuse/index.ts +++ b/src/services/langfuse/index.ts @@ -1,6 +1,7 @@ export { initLangfuse, shutdownLangfuse, + flushLangfuse, isLangfuseEnabled, getLangfuseProcessor, } from './client.js' diff --git a/src/utils/performanceShim.ts b/src/utils/performanceShim.ts new file mode 100644 index 000000000..174518bb6 --- /dev/null +++ b/src/utils/performanceShim.ts @@ -0,0 +1,165 @@ +/** + * Performance shim — replaces globalThis.performance to prevent JSC's C++ Vector + * from growing without bound. + * + * In Bun, globalThis.performance is JSC's native Performance object. It stores + * marks, measures, and resource timings in a C++ Vector that never shrinks even + * after clearMarks(). Long-running sessions (daemon, /loop) accumulate hundreds + * of MB of dead capacity. + * + * This shim keeps performance.now() on the native object (fast, no memory cost) + * but redirects mark/measure/getEntries operations to a plain JS Map that the GC + * can reclaim. Third-party code (React reconciler, OTel/Langfuse) uses + * performance.now() for timing — that stays native. The accumulating operations + * go to GC-able JS memory instead. + * + * MUST be installed before React/OTel import — see cli.tsx first import. + */ + +const original = globalThis.performance + +// JS-backed storage — fully GC-able +const marks = new Map() +const measures = new Map< + string, + { name: string; startTime: number; duration: number } +>() + +function now(): number { + return original.now() +} + +function mark(name: string): PerformanceMark { + marks.set(name, now()) + // Return a minimal PerformanceMark-like object to satisfy the interface. + // React/OTel only use mark() for side effects, not the return value. + return { + name, + entryType: 'mark', + startTime: marks.get(name)!, + duration: 0, + } as PerformanceMark +} + +function measure( + name: string, + startMarkOrOptions?: string | MeasureOptions, + endMark?: string, +): void { + let startTime: number + let duration: number + + if (typeof startMarkOrOptions === 'string') { + const start = marks.get(startMarkOrOptions) + const end = endMark ? marks.get(endMark) : now() + startTime = start ?? now() + duration = (end ?? now()) - startTime + } else if (startMarkOrOptions && typeof startMarkOrOptions === 'object') { + startTime = startMarkOrOptions.start ?? 0 + duration = (startMarkOrOptions.end ?? now()) - startTime + } else { + startTime = 0 + duration = now() + } + + measures.set(name, { name, startTime, duration }) +} + +interface MeasureOptions { + start?: number + end?: number + detail?: unknown +} + +interface PerformanceEntryLike { + readonly name: string + readonly entryType: string + readonly startTime: number + readonly duration: number +} + +function getEntriesByType(type: string): PerformanceEntryLike[] { + if (type === 'mark') { + return [...marks.entries()].map(([name, startTime]) => ({ + name, + entryType: 'mark', + startTime, + duration: 0, + })) + } + if (type === 'measure') { + return [...measures.values()].map(m => ({ + name: m.name, + entryType: 'measure', + startTime: m.startTime, + duration: m.duration, + })) + } + return [] +} + +function getEntriesByName(name: string, type?: string): PerformanceEntryLike[] { + const entries = getEntriesByType(type ?? 'mark').concat( + type === undefined ? getEntriesByType('measure') : [], + ) + return entries.filter(e => e.name === name) +} + +function clearMarks(name?: string): void { + if (name !== undefined) { + marks.delete(name) + } else { + marks.clear() + } +} + +function clearMeasures(name?: string): void { + if (name !== undefined) { + measures.delete(name) + } else { + measures.clear() + } +} + +// Plain object shim — must NOT inherit from Performance.prototype because +// native getters (onresourcetimingbufferfull, timeOrigin, toJSON) check +// that `this` is an actual JSC Performance instance and throw otherwise. +const shim = { + now, + mark, + measure: measure as typeof performance.measure, + getEntriesByType: getEntriesByType as typeof performance.getEntriesByType, + getEntriesByName: getEntriesByName as typeof performance.getEntriesByName, + clearMarks: clearMarks as typeof performance.clearMarks, + clearMeasures: clearMeasures as typeof performance.clearMeasures, + clearResourceTimings: (() => {}) as typeof performance.clearResourceTimings, + setResourceTimingBufferSize: + (() => {}) as typeof performance.setResourceTimingBufferSize, + // Delegate read-only properties to the original + get timeOrigin() { + return original.timeOrigin + }, + get onresourcetimingbufferfull() { + return (original as any).onresourcetimingbufferfull + }, + set onresourcetimingbufferfull(_v: any) { + // no-op — prevent accumulation + }, + toJSON() { + return original.toJSON() + }, +} as typeof performance + +/** + * Install the shim onto globalThis.performance. Safe to call multiple times. + * Must run before React and OTel import to prevent them from capturing the + * native Performance reference. + */ +export function installPerformanceShim(): void { + if ((globalThis as any).__performanceShimInstalled) return + ;(globalThis as any).__performanceShimInstalled = true + globalThis.performance = shim +} + +// Auto-install on import +installPerformanceShim() diff --git a/vite.config.ts b/vite.config.ts index 7a3a1d350..8f52dc0a2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -116,6 +116,9 @@ export default defineConfig({ // Compile-time constant replacement (MACRO.* defines) define: { ...getMacroDefines(), + // React production mode — eliminates _debugStack Error objects + // (6,889 objects × ~1.7KB = 12MB in development builds) + 'process.env.NODE_ENV': JSON.stringify('production'), }, resolve: {