287 lines
8.8 KiB
TypeScript
287 lines
8.8 KiB
TypeScript
import { feature } from 'bun:bundle'
|
|
import type { WriteFileOptions } from 'fs'
|
|
import {
|
|
closeSync,
|
|
writeFileSync as fsWriteFileSync,
|
|
fsyncSync,
|
|
openSync,
|
|
} from 'fs'
|
|
// biome-ignore lint: This file IS the cloneDeep wrapper - it must import the original
|
|
import lodashCloneDeep from 'lodash-es/cloneDeep.js'
|
|
import { addSlowOperation } from '../bootstrap/state.js'
|
|
import { logForDebugging } from './debug.js'
|
|
|
|
// Extended WriteFileOptions to include 'flush' which is available in Node.js 20.1.0+
|
|
// but not yet in @types/node
|
|
type WriteFileOptionsWithFlush =
|
|
| WriteFileOptions
|
|
| (WriteFileOptions & { flush?: boolean })
|
|
|
|
// --- Slow operation logging infrastructure ---
|
|
|
|
/**
|
|
* Threshold in milliseconds for logging slow JSON/clone operations.
|
|
* Operations taking longer than this will be logged for debugging.
|
|
* - Override: set CLAUDE_CODE_SLOW_OPERATION_THRESHOLD_MS to a number
|
|
* - Dev builds: 20ms (lower threshold for development)
|
|
* - Ants: 300ms (enabled for all internal users)
|
|
*/
|
|
const SLOW_OPERATION_THRESHOLD_MS = (() => {
|
|
const envValue = process.env.CLAUDE_CODE_SLOW_OPERATION_THRESHOLD_MS
|
|
if (envValue !== undefined) {
|
|
const parsed = Number(envValue)
|
|
if (!Number.isNaN(parsed) && parsed >= 0) {
|
|
return parsed
|
|
}
|
|
}
|
|
if (process.env.NODE_ENV === 'development') {
|
|
return 20
|
|
}
|
|
if (process.env.USER_TYPE === 'ant') {
|
|
return 300
|
|
}
|
|
return Infinity
|
|
})()
|
|
|
|
// Re-export for callers that still need the threshold value directly
|
|
export { SLOW_OPERATION_THRESHOLD_MS }
|
|
|
|
// Module-level re-entrancy guard. logForDebugging writes to a debug file via
|
|
// appendFileSync, which goes through slowLogging again. Without this guard,
|
|
// a slow appendFileSync → dispose → logForDebugging → appendFileSync → dispose → ...
|
|
let isLogging = false
|
|
|
|
/**
|
|
* Extract the first stack frame outside this file, so the DevBar warning
|
|
* points at the actual caller instead of a useless `Object{N keys}`.
|
|
* Only called when an operation was actually slow — never on the fast path.
|
|
*/
|
|
export function callerFrame(stack: string | undefined): string {
|
|
if (!stack) return ''
|
|
for (const line of stack.split('\n')) {
|
|
if (line.includes('slowOperations')) continue
|
|
const m = line.match(/([^/\\]+?):(\d+):\d+\)?$/)
|
|
if (m) return ` @ ${m[1]}:${m[2]}`
|
|
}
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* Builds a human-readable description from tagged template arguments.
|
|
* Only called when an operation was actually slow — never on the fast path.
|
|
*
|
|
* args[0] = TemplateStringsArray, args[1..n] = interpolated values
|
|
*/
|
|
function buildDescription(args: IArguments): string {
|
|
const strings = args[0] as TemplateStringsArray
|
|
let result = ''
|
|
for (let i = 0; i < strings.length; i++) {
|
|
result += strings[i]
|
|
if (i + 1 < args.length) {
|
|
const v = args[i + 1]
|
|
if (Array.isArray(v)) {
|
|
result += `Array[${(v as unknown[]).length}]`
|
|
} else if (v !== null && typeof v === 'object') {
|
|
result += `Object{${Object.keys(v as Record<string, unknown>).length} keys}`
|
|
} else if (typeof v === 'string') {
|
|
result += v.length > 80 ? `${v.slice(0, 80)}…` : v
|
|
} else {
|
|
result += String(v)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
class AntSlowLogger {
|
|
startTime: number
|
|
args: IArguments
|
|
err: Error
|
|
|
|
constructor(args: IArguments) {
|
|
this.startTime = performance.now()
|
|
this.args = args
|
|
// V8/JSC capture the stack at construction but defer the expensive string
|
|
// formatting until .stack is read — so this stays off the fast path.
|
|
this.err = new Error()
|
|
}
|
|
|
|
[Symbol.dispose](): void {
|
|
const duration = performance.now() - this.startTime
|
|
if (duration > SLOW_OPERATION_THRESHOLD_MS && !isLogging) {
|
|
isLogging = true
|
|
try {
|
|
const description =
|
|
buildDescription(this.args) + callerFrame(this.err.stack)
|
|
logForDebugging(
|
|
`[SLOW OPERATION DETECTED] ${description} (${duration.toFixed(1)}ms)`,
|
|
)
|
|
addSlowOperation(description, duration)
|
|
} finally {
|
|
isLogging = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const NOOP_LOGGER: Disposable = { [Symbol.dispose]() {} }
|
|
|
|
// Must be regular functions (not arrows) to access `arguments`
|
|
function slowLoggingAnt(
|
|
_strings: TemplateStringsArray,
|
|
..._values: unknown[]
|
|
): AntSlowLogger {
|
|
// eslint-disable-next-line prefer-rest-params
|
|
return new AntSlowLogger(arguments)
|
|
}
|
|
|
|
function slowLoggingExternal(): Disposable {
|
|
return NOOP_LOGGER
|
|
}
|
|
|
|
/**
|
|
* Tagged template for slow operation logging.
|
|
*
|
|
* In ANT builds: creates an AntSlowLogger that times the operation and logs
|
|
* if it exceeds the threshold. Description is built lazily only when slow.
|
|
*
|
|
* In external builds: returns a singleton no-op disposable. Zero allocations,
|
|
* zero timing. AntSlowLogger and buildDescription are dead-code-eliminated.
|
|
*
|
|
* @example
|
|
* using _ = slowLogging`structuredClone(${value})`
|
|
* const result = structuredClone(value)
|
|
*/
|
|
export const slowLogging: {
|
|
(strings: TemplateStringsArray, ...values: unknown[]): Disposable
|
|
} = feature('SLOW_OPERATION_LOGGING') ? slowLoggingAnt : slowLoggingExternal
|
|
|
|
// --- Wrapped operations ---
|
|
|
|
/**
|
|
* Wrapped JSON.stringify with slow operation logging.
|
|
* Use this instead of JSON.stringify directly to detect performance issues.
|
|
*
|
|
* @example
|
|
* import { jsonStringify } from './slowOperations.js'
|
|
* const json = jsonStringify(data)
|
|
* const prettyJson = jsonStringify(data, null, 2)
|
|
*/
|
|
export function jsonStringify(
|
|
value: unknown,
|
|
replacer?: (this: unknown, key: string, value: unknown) => unknown,
|
|
space?: string | number,
|
|
): string
|
|
export function jsonStringify(
|
|
value: unknown,
|
|
replacer?: (number | string)[] | null,
|
|
space?: string | number,
|
|
): string
|
|
export function jsonStringify(
|
|
value: unknown,
|
|
replacer?:
|
|
| ((this: unknown, key: string, value: unknown) => unknown)
|
|
| (number | string)[]
|
|
| null,
|
|
space?: string | number,
|
|
): string {
|
|
using _ = slowLogging`JSON.stringify(${value})`
|
|
return JSON.stringify(
|
|
value,
|
|
replacer as Parameters<typeof JSON.stringify>[1],
|
|
space,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Wrapped JSON.parse with slow operation logging.
|
|
* Use this instead of JSON.parse directly to detect performance issues.
|
|
*
|
|
* @example
|
|
* import { jsonParse } from './slowOperations.js'
|
|
* const data = jsonParse(jsonString)
|
|
*/
|
|
export const jsonParse: typeof JSON.parse = (text, reviver) => {
|
|
using _ = slowLogging`JSON.parse(${text})`
|
|
// V8 de-opts JSON.parse when a second argument is passed, even if undefined.
|
|
// Branch explicitly so the common (no-reviver) path stays on the fast path.
|
|
return typeof reviver === 'undefined'
|
|
? JSON.parse(text)
|
|
: JSON.parse(text, reviver)
|
|
}
|
|
|
|
/**
|
|
* Wrapped structuredClone with slow operation logging.
|
|
* Use this instead of structuredClone directly to detect performance issues.
|
|
*
|
|
* @example
|
|
* import { clone } from './slowOperations.js'
|
|
* const copy = clone(originalObject)
|
|
*/
|
|
export function clone<T>(value: T, options?: StructuredSerializeOptions): T {
|
|
using _ = slowLogging`structuredClone(${value})`
|
|
return structuredClone(value, options)
|
|
}
|
|
|
|
/**
|
|
* Wrapped cloneDeep with slow operation logging.
|
|
* Use this instead of lodash cloneDeep directly to detect performance issues.
|
|
*
|
|
* @example
|
|
* import { cloneDeep } from './slowOperations.js'
|
|
* const copy = cloneDeep(originalObject)
|
|
*/
|
|
export function cloneDeep<T>(value: T): T {
|
|
using _ = slowLogging`cloneDeep(${value})`
|
|
return lodashCloneDeep(value)
|
|
}
|
|
|
|
/**
|
|
* Wrapper around fs.writeFileSync with slow operation logging.
|
|
* Supports flush option to ensure data is written to disk before returning.
|
|
* @param filePath The path to the file to write to
|
|
* @param data The data to write (string or Buffer)
|
|
* @param options Optional write options (encoding, mode, flag, flush)
|
|
* @deprecated Use `fs.promises.writeFile` instead for non-blocking writes.
|
|
* Sync file writes block the event loop and cause performance issues.
|
|
*/
|
|
export function writeFileSync_DEPRECATED(
|
|
filePath: string,
|
|
data: string | NodeJS.ArrayBufferView,
|
|
options?: WriteFileOptionsWithFlush,
|
|
): void {
|
|
using _ = slowLogging`fs.writeFileSync(${filePath}, ${data})`
|
|
|
|
// Check if flush is requested (for object-style options)
|
|
const needsFlush =
|
|
options !== null &&
|
|
typeof options === 'object' &&
|
|
'flush' in options &&
|
|
options.flush === true
|
|
|
|
if (needsFlush) {
|
|
// Manual flush: open file, write, fsync, close
|
|
const encoding =
|
|
typeof options === 'object' && 'encoding' in options
|
|
? options.encoding
|
|
: undefined
|
|
const mode =
|
|
typeof options === 'object' && 'mode' in options
|
|
? options.mode
|
|
: undefined
|
|
let fd: number | undefined
|
|
try {
|
|
fd = openSync(filePath, 'w', mode)
|
|
fsWriteFileSync(fd, data, { encoding: encoding ?? undefined })
|
|
fsyncSync(fd)
|
|
} finally {
|
|
if (fd !== undefined) {
|
|
closeSync(fd)
|
|
}
|
|
}
|
|
} else {
|
|
// No flush needed, use standard writeFileSync
|
|
fsWriteFileSync(filePath, data, options as WriteFileOptions)
|
|
}
|
|
}
|