/ src / utils / slowOperations.ts
slowOperations.ts
  1  import { feature } from 'bun:bundle'
  2  import type { WriteFileOptions } from 'fs'
  3  import {
  4    closeSync,
  5    writeFileSync as fsWriteFileSync,
  6    fsyncSync,
  7    openSync,
  8  } from 'fs'
  9  // biome-ignore lint: This file IS the cloneDeep wrapper - it must import the original
 10  import lodashCloneDeep from 'lodash-es/cloneDeep.js'
 11  import { addSlowOperation } from '../bootstrap/state.js'
 12  import { logForDebugging } from './debug.js'
 13  
 14  // Extended WriteFileOptions to include 'flush' which is available in Node.js 20.1.0+
 15  // but not yet in @types/node
 16  type WriteFileOptionsWithFlush =
 17    | WriteFileOptions
 18    | (WriteFileOptions & { flush?: boolean })
 19  
 20  // --- Slow operation logging infrastructure ---
 21  
 22  /**
 23   * Threshold in milliseconds for logging slow JSON/clone operations.
 24   * Operations taking longer than this will be logged for debugging.
 25   * - Override: set CLAUDE_CODE_SLOW_OPERATION_THRESHOLD_MS to a number
 26   * - Dev builds: 20ms (lower threshold for development)
 27   * - Ants: 300ms (enabled for all internal users)
 28   */
 29  const SLOW_OPERATION_THRESHOLD_MS = (() => {
 30    const envValue = process.env.CLAUDE_CODE_SLOW_OPERATION_THRESHOLD_MS
 31    if (envValue !== undefined) {
 32      const parsed = Number(envValue)
 33      if (!Number.isNaN(parsed) && parsed >= 0) {
 34        return parsed
 35      }
 36    }
 37    if (process.env.NODE_ENV === 'development') {
 38      return 20
 39    }
 40    if (process.env.USER_TYPE === 'ant') {
 41      return 300
 42    }
 43    return Infinity
 44  })()
 45  
 46  // Re-export for callers that still need the threshold value directly
 47  export { SLOW_OPERATION_THRESHOLD_MS }
 48  
 49  // Module-level re-entrancy guard. logForDebugging writes to a debug file via
 50  // appendFileSync, which goes through slowLogging again. Without this guard,
 51  // a slow appendFileSync → dispose → logForDebugging → appendFileSync → dispose → ...
 52  let isLogging = false
 53  
 54  /**
 55   * Extract the first stack frame outside this file, so the DevBar warning
 56   * points at the actual caller instead of a useless `Object{N keys}`.
 57   * Only called when an operation was actually slow — never on the fast path.
 58   */
 59  export function callerFrame(stack: string | undefined): string {
 60    if (!stack) return ''
 61    for (const line of stack.split('\n')) {
 62      if (line.includes('slowOperations')) continue
 63      const m = line.match(/([^/\\]+?):(\d+):\d+\)?$/)
 64      if (m) return ` @ ${m[1]}:${m[2]}`
 65    }
 66    return ''
 67  }
 68  
 69  /**
 70   * Builds a human-readable description from tagged template arguments.
 71   * Only called when an operation was actually slow — never on the fast path.
 72   *
 73   * args[0] = TemplateStringsArray, args[1..n] = interpolated values
 74   */
 75  function buildDescription(args: IArguments): string {
 76    const strings = args[0] as TemplateStringsArray
 77    let result = ''
 78    for (let i = 0; i < strings.length; i++) {
 79      result += strings[i]
 80      if (i + 1 < args.length) {
 81        const v = args[i + 1]
 82        if (Array.isArray(v)) {
 83          result += `Array[${(v as unknown[]).length}]`
 84        } else if (v !== null && typeof v === 'object') {
 85          result += `Object{${Object.keys(v as Record<string, unknown>).length} keys}`
 86        } else if (typeof v === 'string') {
 87          result += v.length > 80 ? `${v.slice(0, 80)}…` : v
 88        } else {
 89          result += String(v)
 90        }
 91      }
 92    }
 93    return result
 94  }
 95  
 96  class AntSlowLogger {
 97    startTime: number
 98    args: IArguments
 99    err: Error
100  
101    constructor(args: IArguments) {
102      this.startTime = performance.now()
103      this.args = args
104      // V8/JSC capture the stack at construction but defer the expensive string
105      // formatting until .stack is read — so this stays off the fast path.
106      this.err = new Error()
107    }
108  
109    [Symbol.dispose](): void {
110      const duration = performance.now() - this.startTime
111      if (duration > SLOW_OPERATION_THRESHOLD_MS && !isLogging) {
112        isLogging = true
113        try {
114          const description =
115            buildDescription(this.args) + callerFrame(this.err.stack)
116          logForDebugging(
117            `[SLOW OPERATION DETECTED] ${description} (${duration.toFixed(1)}ms)`,
118          )
119          addSlowOperation(description, duration)
120        } finally {
121          isLogging = false
122        }
123      }
124    }
125  }
126  
127  const NOOP_LOGGER: Disposable = { [Symbol.dispose]() {} }
128  
129  // Must be regular functions (not arrows) to access `arguments`
130  function slowLoggingAnt(
131    _strings: TemplateStringsArray,
132    ..._values: unknown[]
133  ): AntSlowLogger {
134    // eslint-disable-next-line prefer-rest-params
135    return new AntSlowLogger(arguments)
136  }
137  
138  function slowLoggingExternal(): Disposable {
139    return NOOP_LOGGER
140  }
141  
142  /**
143   * Tagged template for slow operation logging.
144   *
145   * In ANT builds: creates an AntSlowLogger that times the operation and logs
146   * if it exceeds the threshold. Description is built lazily only when slow.
147   *
148   * In external builds: returns a singleton no-op disposable. Zero allocations,
149   * zero timing. AntSlowLogger and buildDescription are dead-code-eliminated.
150   *
151   * @example
152   * using _ = slowLogging`structuredClone(${value})`
153   * const result = structuredClone(value)
154   */
155  export const slowLogging: {
156    (strings: TemplateStringsArray, ...values: unknown[]): Disposable
157  } = feature('SLOW_OPERATION_LOGGING') ? slowLoggingAnt : slowLoggingExternal
158  
159  // --- Wrapped operations ---
160  
161  /**
162   * Wrapped JSON.stringify with slow operation logging.
163   * Use this instead of JSON.stringify directly to detect performance issues.
164   *
165   * @example
166   * import { jsonStringify } from './slowOperations.js'
167   * const json = jsonStringify(data)
168   * const prettyJson = jsonStringify(data, null, 2)
169   */
170  export function jsonStringify(
171    value: unknown,
172    replacer?: (this: unknown, key: string, value: unknown) => unknown,
173    space?: string | number,
174  ): string
175  export function jsonStringify(
176    value: unknown,
177    replacer?: (number | string)[] | null,
178    space?: string | number,
179  ): string
180  export function jsonStringify(
181    value: unknown,
182    replacer?:
183      | ((this: unknown, key: string, value: unknown) => unknown)
184      | (number | string)[]
185      | null,
186    space?: string | number,
187  ): string {
188    using _ = slowLogging`JSON.stringify(${value})`
189    return JSON.stringify(
190      value,
191      replacer as Parameters<typeof JSON.stringify>[1],
192      space,
193    )
194  }
195  
196  /**
197   * Wrapped JSON.parse with slow operation logging.
198   * Use this instead of JSON.parse directly to detect performance issues.
199   *
200   * @example
201   * import { jsonParse } from './slowOperations.js'
202   * const data = jsonParse(jsonString)
203   */
204  export const jsonParse: typeof JSON.parse = (text, reviver) => {
205    using _ = slowLogging`JSON.parse(${text})`
206    // V8 de-opts JSON.parse when a second argument is passed, even if undefined.
207    // Branch explicitly so the common (no-reviver) path stays on the fast path.
208    return typeof reviver === 'undefined'
209      ? JSON.parse(text)
210      : JSON.parse(text, reviver)
211  }
212  
213  /**
214   * Wrapped structuredClone with slow operation logging.
215   * Use this instead of structuredClone directly to detect performance issues.
216   *
217   * @example
218   * import { clone } from './slowOperations.js'
219   * const copy = clone(originalObject)
220   */
221  export function clone<T>(value: T, options?: StructuredSerializeOptions): T {
222    using _ = slowLogging`structuredClone(${value})`
223    return structuredClone(value, options)
224  }
225  
226  /**
227   * Wrapped cloneDeep with slow operation logging.
228   * Use this instead of lodash cloneDeep directly to detect performance issues.
229   *
230   * @example
231   * import { cloneDeep } from './slowOperations.js'
232   * const copy = cloneDeep(originalObject)
233   */
234  export function cloneDeep<T>(value: T): T {
235    using _ = slowLogging`cloneDeep(${value})`
236    return lodashCloneDeep(value)
237  }
238  
239  /**
240   * Wrapper around fs.writeFileSync with slow operation logging.
241   * Supports flush option to ensure data is written to disk before returning.
242   * @param filePath The path to the file to write to
243   * @param data The data to write (string or Buffer)
244   * @param options Optional write options (encoding, mode, flag, flush)
245   * @deprecated Use `fs.promises.writeFile` instead for non-blocking writes.
246   * Sync file writes block the event loop and cause performance issues.
247   */
248  export function writeFileSync_DEPRECATED(
249    filePath: string,
250    data: string | NodeJS.ArrayBufferView,
251    options?: WriteFileOptionsWithFlush,
252  ): void {
253    using _ = slowLogging`fs.writeFileSync(${filePath}, ${data})`
254  
255    // Check if flush is requested (for object-style options)
256    const needsFlush =
257      options !== null &&
258      typeof options === 'object' &&
259      'flush' in options &&
260      options.flush === true
261  
262    if (needsFlush) {
263      // Manual flush: open file, write, fsync, close
264      const encoding =
265        typeof options === 'object' && 'encoding' in options
266          ? options.encoding
267          : undefined
268      const mode =
269        typeof options === 'object' && 'mode' in options
270          ? options.mode
271          : undefined
272      let fd: number | undefined
273      try {
274        fd = openSync(filePath, 'w', mode)
275        fsWriteFileSync(fd, data, { encoding: encoding ?? undefined })
276        fsyncSync(fd)
277      } finally {
278        if (fd !== undefined) {
279          closeSync(fd)
280        }
281      }
282    } else {
283      // No flush needed, use standard writeFileSync
284      fsWriteFileSync(filePath, data, options as WriteFileOptions)
285    }
286  }