diff --git a/packages/debugger/src/domain/api.spec.ts b/packages/debugger/src/domain/api.spec.ts index 16e78b3cd9..84ed85063b 100644 --- a/packages/debugger/src/domain/api.spec.ts +++ b/packages/debugger/src/domain/api.spec.ts @@ -558,6 +558,246 @@ describe('api', () => { }) }) + describe('snapshot timeout', () => { + function createSnapshotProbe(methodName: string): Probe { + return { + id: `timeout-probe-${methodName}`, + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 3 }, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt: 'ENTRY', + } + } + + it('should drop snapshot when entry capture exceeds timeout', () => { + const probe = createSnapshotProbe('entryTimeout') + addProbe(probe) + + let callCount = 0 + const realNow = performance.now.bind(performance) + spyOn(performance, 'now').and.callFake(() => { + callCount++ + // Let the first few calls (start time, deadline creation) use real time, + // then jump past the deadline to simulate slow capture. + if (callCount <= 3) { + return realNow() + } + return realNow() + 20 + }) + + const probes = getProbes('TestClass;entryTimeout')! + const deepObj = { level1: { level2: { level3: { level4: 'deep' } } } } + onEntry(probes, {}, { arg: deepObj }) + onReturn(probes, null, {}, { arg: deepObj }, {}) + + // The entry capture timed out, so onEntry pushed null. + // onReturn still gets an active entry from its own onEntry call, but + // the entry snapshot is dropped. The return capture has its own timeout. + // Since performance.now is still returning future values, the return + // capture also times out and no snapshot is sent. + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + + it('should drop snapshot when return capture exceeds timeout', () => { + const probe = createSnapshotProbe('returnTimeout') + addProbe(probe) + + const probes = getProbes('TestClass;returnTimeout')! + + // Let onEntry succeed with real time + onEntry(probes, {}, { x: 1 }) + + // Now make performance.now jump forward so the return capture times out + let callCount = 0 + const realNow = performance.now.bind(performance) + spyOn(performance, 'now').and.callFake(() => { + callCount++ + if (callCount <= 2) { + return realNow() + } + return realNow() + 20 + }) + + onReturn(probes, null, {}, { x: 1 }, { local: 'value' }) + + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + + it('should drop snapshot when throw capture exceeds timeout', () => { + const probe = createSnapshotProbe('throwTimeout') + addProbe(probe) + + const probes = getProbes('TestClass;throwTimeout')! + + // Let onEntry succeed with real time + onEntry(probes, {}, { x: 1 }) + + // Now make performance.now jump forward so the throw capture times out + let callCount = 0 + const realNow = performance.now.bind(performance) + spyOn(performance, 'now').and.callFake(() => { + callCount++ + if (callCount <= 2) { + return realNow() + } + return realNow() + 20 + }) + + onThrow(probes, new Error('test'), {}, { x: 1 }) + + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + + it('should not affect non-snapshot probes', () => { + const probe: Probe = { + id: 'non-snapshot-timeout', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'nonSnapshot' }, + template: 'Test', + captureSnapshot: false, + capture: {}, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + // Spike performance.now to simulate slow execution + let callCount = 0 + const realNow = performance.now.bind(performance) + spyOn(performance, 'now').and.callFake(() => { + callCount++ + if (callCount <= 2) { + return realNow() + } + return realNow() + 20 + }) + + const probes = getProbes('TestClass;nonSnapshot')! + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + + expect(mockBatchAdd).toHaveBeenCalledTimes(1) + }) + + it('should not leak active entries when entry capture times out', () => { + const probe = createSnapshotProbe('entryLeakTest') + addProbe(probe) + + let shouldTimeout = true + let callCount = 0 + const realNow = performance.now.bind(performance) + spyOn(performance, 'now').and.callFake(() => { + callCount++ + if (!shouldTimeout || callCount <= 3) { + return realNow() + } + return realNow() + 20 + }) + + const probes = getProbes('TestClass;entryLeakTest')! + // This onEntry will time out and push null + onEntry(probes, {}, { x: 1 }) + + // onReturn should handle the null entry gracefully (no snapshot sent) + shouldTimeout = false + callCount = 0 + onReturn(probes, null, {}, { x: 1 }, {}) + + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + + it('should skip subsequent snapshot probes after timeout but still process non-snapshot probes', () => { + const snapshotProbe1: Probe = { + id: 'timeout-shared-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'sharedDeadline' }, + template: 'Snapshot probe', + captureSnapshot: true, + capture: { maxReferenceDepth: 3 }, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt: 'ENTRY', + } + const nonSnapshotProbe: Probe = { + id: 'timeout-shared-2', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'sharedDeadline' }, + template: 'Non-snapshot probe', + captureSnapshot: false, + capture: {}, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt: 'ENTRY', + } + const snapshotProbe2: Probe = { + id: 'timeout-shared-3', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'sharedDeadline' }, + template: 'Second snapshot probe', + captureSnapshot: true, + capture: { maxReferenceDepth: 3 }, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt: 'ENTRY', + } + addProbe(snapshotProbe1) + addProbe(nonSnapshotProbe) + addProbe(snapshotProbe2) + + let callCount = 0 + const realNow = performance.now.bind(performance) + spyOn(performance, 'now').and.callFake(() => { + callCount++ + if (callCount <= 3) { + return realNow() + } + return realNow() + 20 + }) + + const probes = getProbes('TestClass;sharedDeadline')! + onEntry(probes, {}, { x: 1 }) + onReturn(probes, null, {}, { x: 1 }, {}) + + // The non-snapshot probe should still send, but both snapshot probes should be dropped + const calls = mockBatchAdd.calls.allArgs() + expect(calls.length).toBe(1) + expect(calls[0][0].message).toBe('Non-snapshot probe') + }) + + it('should share deadline across probes so second snapshot probe exits immediately', () => { + const probe1 = createSnapshotProbe('sharedDeadline1') + const probe2: Probe = { + ...createSnapshotProbe('sharedDeadline2'), + id: 'timeout-probe-sharedDeadline2', + where: { typeName: 'TestClass', methodName: 'sharedDeadline1' }, + } + addProbe(probe1) + addProbe(probe2) + + let callCount = 0 + const realNow = performance.now.bind(performance) + spyOn(performance, 'now').and.callFake(() => { + callCount++ + if (callCount <= 3) { + return realNow() + } + return realNow() + 20 + }) + + const probes = getProbes('TestClass;sharedDeadline1')! + onEntry(probes, {}, { x: 1 }) + onReturn(probes, null, {}, { x: 1 }, {}) + + // Both snapshot probes share the deadline -- neither should send + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + }) + describe('error handling', () => { it('should handle missing DD_RUM gracefully', () => { delete (window as any).DD_RUM diff --git a/packages/debugger/src/domain/api.ts b/packages/debugger/src/domain/api.ts index d076b73705..86552927b7 100644 --- a/packages/debugger/src/domain/api.ts +++ b/packages/debugger/src/domain/api.ts @@ -3,6 +3,7 @@ import type { Batch, Context } from '@datadog/browser-core' import { timeStampNow, display, buildTag, generateUUID, getGlobalObject } from '@datadog/browser-core' import type { BrowserWindow, DebuggerInitConfiguration } from '../entries/main' import { capture, captureFields } from './capture' +import type { CaptureContext } from './capture' import type { InitializedProbe } from './probes' import { checkGlobalSnapshotBudget } from './probes' import type { ActiveEntry } from './activeEntries' @@ -15,6 +16,8 @@ const globalObj = getGlobalObject() // eslint-disable-line local- const threadName = detectThreadName() // eslint-disable-line local-rules/disallow-side-effects +const SNAPSHOT_TIMEOUT_MS = 10 + let debuggerBatch: Batch | undefined let debuggerConfig: DebuggerInitConfiguration | undefined let cachedDDtags: string | undefined @@ -41,6 +44,7 @@ export function resetDebuggerTransport(): void { */ export function onEntry(probes: InitializedProbe[], self: any, args: Record): void { const start = performance.now() + const captureCtx: CaptureContext = { deadline: start + SNAPSHOT_TIMEOUT_MS, timedOut: false } // TODO: A lot of repeated work performed for each probe that could be shared between probes for (const probe of probes) { @@ -81,14 +85,19 @@ export function onEntry(probes: InitializedProbe[], self: any, args: Record } | undefined + if (shouldCaptureEntrySnapshot) { + entry = { + arguments: { + ...captureFields(args, probe.capture, captureCtx), + this: capture(self, probe.capture, captureCtx), + }, + } + if (captureCtx.timedOut) { + stack.push(null) + continue + } + } stack.push({ start, @@ -118,6 +127,7 @@ export function onReturn( locals: Record ): any { const end = performance.now() + const captureCtx: CaptureContext = { deadline: performance.now() + SNAPSHOT_TIMEOUT_MS, timedOut: false } // TODO: A lot of repeated work performed for each probe that could be shared between probes for (const probe of probes) { @@ -153,18 +163,21 @@ export function onReturn( result.message = evaluateProbeMessage(probe, context) } - result.return = probe.captureSnapshot - ? { - arguments: { - ...captureFields(args, probe.capture), - this: capture(self, probe.capture), - }, - locals: { - ...captureFields(locals, probe.capture), - '@return': capture(value, probe.capture), - }, - } - : undefined + if (probe.captureSnapshot) { + result.return = { + arguments: { + ...captureFields(args, probe.capture, captureCtx), + this: capture(self, probe.capture, captureCtx), + }, + locals: { + ...captureFields(locals, probe.capture, captureCtx), + '@return': capture(value, probe.capture, captureCtx), + }, + } + if (captureCtx.timedOut) { + continue + } + } sendDebuggerSnapshot(probe, result) } @@ -182,6 +195,7 @@ export function onReturn( */ export function onThrow(probes: InitializedProbe[], error: Error, self: any, args: Record): void { const end = performance.now() + const captureCtx: CaptureContext = { deadline: performance.now() + SNAPSHOT_TIMEOUT_MS, timedOut: false } // TODO: A lot of repeated work performed for each probe that could be shared between probes for (const probe of probes) { @@ -217,13 +231,19 @@ export function onThrow(probes: InitializedProbe[], error: Error, self: any, arg result.message = evaluateProbeMessage(probe, context) } + let throwArguments: Record | undefined + if (probe.captureSnapshot) { + throwArguments = { + ...captureFields(args, probe.capture, captureCtx), + this: capture(self, probe.capture, captureCtx), + } + if (captureCtx.timedOut) { + continue + } + } + result.return = { - arguments: probe.captureSnapshot - ? { - ...captureFields(args, probe.capture), - this: capture(self, probe.capture), - } - : undefined, + arguments: throwArguments, throwable: { message: error.message, stacktrace: parseStackTrace(error), diff --git a/packages/debugger/src/domain/capture.spec.ts b/packages/debugger/src/domain/capture.spec.ts index dfb4a5f795..a37d3fcf0d 100644 --- a/packages/debugger/src/domain/capture.spec.ts +++ b/packages/debugger/src/domain/capture.spec.ts @@ -1,4 +1,9 @@ import { capture, captureFields } from './capture' +import type { CaptureContext } from './capture' + +function noTimeout(): CaptureContext { + return { deadline: Infinity, timedOut: false } +} describe('capture', () => { const defaultOpts = { @@ -10,29 +15,29 @@ describe('capture', () => { describe('primitive types', () => { it('should capture null', () => { - const result = capture(null, defaultOpts) + const result = capture(null, defaultOpts, noTimeout()) expect(result).toEqual({ type: 'null', isNull: true }) }) it('should capture undefined', () => { - const result = capture(undefined, defaultOpts) + const result = capture(undefined, defaultOpts, noTimeout()) expect(result).toEqual({ type: 'undefined' }) }) it('should capture boolean', () => { - expect(capture(true, defaultOpts)).toEqual({ type: 'boolean', value: 'true' }) - expect(capture(false, defaultOpts)).toEqual({ type: 'boolean', value: 'false' }) + expect(capture(true, defaultOpts, noTimeout())).toEqual({ type: 'boolean', value: 'true' }) + expect(capture(false, defaultOpts, noTimeout())).toEqual({ type: 'boolean', value: 'false' }) }) it('should capture number', () => { - expect(capture(42, defaultOpts)).toEqual({ type: 'number', value: '42' }) - expect(capture(3.14, defaultOpts)).toEqual({ type: 'number', value: '3.14' }) - expect(capture(NaN, defaultOpts)).toEqual({ type: 'number', value: 'NaN' }) - expect(capture(Infinity, defaultOpts)).toEqual({ type: 'number', value: 'Infinity' }) + expect(capture(42, defaultOpts, noTimeout())).toEqual({ type: 'number', value: '42' }) + expect(capture(3.14, defaultOpts, noTimeout())).toEqual({ type: 'number', value: '3.14' }) + expect(capture(NaN, defaultOpts, noTimeout())).toEqual({ type: 'number', value: 'NaN' }) + expect(capture(Infinity, defaultOpts, noTimeout())).toEqual({ type: 'number', value: 'Infinity' }) }) it('should capture string', () => { - const result = capture('hello', defaultOpts) + const result = capture('hello', defaultOpts, noTimeout()) expect(result).toEqual({ type: 'string', value: 'hello' }) }) @@ -41,7 +46,7 @@ describe('capture', () => { pending('BigInt is not supported in this browser') return } - const result = capture(BigInt(123), defaultOpts) + const result = capture(BigInt(123), defaultOpts, noTimeout()) expect(result).toEqual({ type: 'bigint', value: '123' }) }) @@ -51,13 +56,13 @@ describe('capture', () => { return } const sym = Symbol('test') - const result = capture(sym, defaultOpts) + const result = capture(sym, defaultOpts, noTimeout()) expect(result).toEqual({ type: 'symbol', value: 'test' }) }) it('should capture symbol without description', () => { const sym = Symbol() - const result = capture(sym, defaultOpts) + const result = capture(sym, defaultOpts, noTimeout()) expect(result).toEqual({ type: 'symbol', value: '' }) }) }) @@ -65,7 +70,7 @@ describe('capture', () => { describe('string truncation', () => { it('should truncate long strings', () => { const longString = 'a'.repeat(300) - const result = capture(longString, { ...defaultOpts, maxLength: 10 }) + const result = capture(longString, { ...defaultOpts, maxLength: 10 }, noTimeout()) expect(result).toEqual({ type: 'string', @@ -76,7 +81,7 @@ describe('capture', () => { }) it('should not truncate strings under maxLength', () => { - const result = capture('short', { ...defaultOpts, maxLength: 10 }) + const result = capture('short', { ...defaultOpts, maxLength: 10 }, noTimeout()) expect(result).toEqual({ type: 'string', value: 'short' }) }) }) @@ -84,25 +89,25 @@ describe('capture', () => { describe('built-in objects', () => { it('should capture Date', () => { const date = new Date('2024-01-01T00:00:00.000Z') - const result = capture(date, defaultOpts) + const result = capture(date, defaultOpts, noTimeout()) expect(result).toEqual({ type: 'Date', value: '2024-01-01T00:00:00.000Z' }) }) it('should capture invalid Date without throwing', () => { const date = new Date('invalid') - const result = capture(date, defaultOpts) + const result = capture(date, defaultOpts, noTimeout()) expect(result).toEqual({ type: 'Date', value: 'Invalid Date' }) }) it('should capture RegExp', () => { const regex = /test/gi - const result = capture(regex, defaultOpts) + const result = capture(regex, defaultOpts, noTimeout()) expect(result).toEqual({ type: 'RegExp', value: '/test/gi' }) }) it('should capture Error', () => { const error = new Error('test error') - const result = capture(error, defaultOpts) as any + const result = capture(error, defaultOpts, noTimeout()) as any expect(result).toEqual({ type: 'Error', @@ -122,7 +127,7 @@ describe('capture', () => { } } const error = new CustomError('custom error') - const result = capture(error, defaultOpts) as any + const result = capture(error, defaultOpts, noTimeout()) as any expect(result.type).toBe('CustomError') expect(result.fields.name).toEqual({ type: 'string', value: 'CustomError' }) @@ -136,7 +141,7 @@ describe('capture', () => { pending('Error cause is not supported in this browser') return } - const result = capture(error, defaultOpts) as any + const result = capture(error, defaultOpts, noTimeout()) as any expect(result.fields.cause).toEqual({ type: 'Error', @@ -150,7 +155,7 @@ describe('capture', () => { it('should capture Promise', () => { const promise = Promise.resolve(42) - const result = capture(promise, defaultOpts) + const result = capture(promise, defaultOpts, noTimeout()) expect(result).toEqual({ type: 'Promise', notCapturedReason: 'Promise state cannot be inspected' }) }) }) @@ -158,7 +163,7 @@ describe('capture', () => { describe('arrays', () => { it('should capture array', () => { const arr = [1, 'two', true] - const result = capture(arr, defaultOpts) as any + const result = capture(arr, defaultOpts, noTimeout()) as any expect(result.type).toBe('Array') expect(result.elements).toEqual([ @@ -170,7 +175,7 @@ describe('capture', () => { it('should truncate large arrays', () => { const arr = Array(200).fill(1) - const result = capture(arr, { ...defaultOpts, maxCollectionSize: 3 }) as any + const result = capture(arr, { ...defaultOpts, maxCollectionSize: 3 }, noTimeout()) as any expect(result.type).toBe('Array') expect(result.elements.length).toBe(3) @@ -183,7 +188,7 @@ describe('capture', () => { [1, 2], [3, 4], ] - const result = capture(arr, defaultOpts) as any + const result = capture(arr, defaultOpts, noTimeout()) as any expect(result.type).toBe('Array') expect(result.elements[0].type).toBe('Array') @@ -200,7 +205,7 @@ describe('capture', () => { ['key1', 'value1'], ['key2', 42], ]) - const result = capture(map, defaultOpts) as any + const result = capture(map, defaultOpts, noTimeout()) as any expect(result.type).toBe('Map') expect(result.entries).toEqual([ @@ -220,7 +225,7 @@ describe('capture', () => { for (let i = 0; i < 200; i++) { map.set(`key${i}`, i) } - const result = capture(map, { ...defaultOpts, maxCollectionSize: 3 }) as any + const result = capture(map, { ...defaultOpts, maxCollectionSize: 3 }, noTimeout()) as any expect(result.entries.length).toBe(3) expect(result.notCapturedReason).toBe('collectionSize') @@ -229,7 +234,7 @@ describe('capture', () => { it('should capture Set', () => { const set = new Set([1, 'two', true]) - const result = capture(set, defaultOpts) as any + const result = capture(set, defaultOpts, noTimeout()) as any expect(result.type).toBe('Set') expect(result.elements).toEqual([ @@ -244,7 +249,7 @@ describe('capture', () => { for (let i = 0; i < 200; i++) { set.add(i) } - const result = capture(set, { ...defaultOpts, maxCollectionSize: 3 }) as any + const result = capture(set, { ...defaultOpts, maxCollectionSize: 3 }, noTimeout()) as any expect(result.elements.length).toBe(3) expect(result.notCapturedReason).toBe('collectionSize') @@ -253,13 +258,13 @@ describe('capture', () => { it('should handle WeakMap', () => { const weakMap = new WeakMap() - const result = capture(weakMap, defaultOpts) + const result = capture(weakMap, defaultOpts, noTimeout()) expect(result).toEqual({ type: 'WeakMap', notCapturedReason: 'WeakMap contents cannot be enumerated' }) }) it('should handle WeakSet', () => { const weakSet = new WeakSet() - const result = capture(weakSet, defaultOpts) + const result = capture(weakSet, defaultOpts, noTimeout()) expect(result).toEqual({ type: 'WeakSet', notCapturedReason: 'WeakSet contents cannot be enumerated' }) }) }) @@ -267,7 +272,7 @@ describe('capture', () => { describe('objects', () => { it('should capture plain object', () => { const obj = { a: 1, b: 'two' } - const result = capture(obj, defaultOpts) as any + const result = capture(obj, defaultOpts, noTimeout()) as any expect(result.type).toBe('Object') expect(result.fields.a).toEqual({ type: 'number', value: '1' }) @@ -276,7 +281,7 @@ describe('capture', () => { it('should capture nested objects', () => { const obj = { outer: { inner: 'value' } } - const result = capture(obj, defaultOpts) as any + const result = capture(obj, defaultOpts, noTimeout()) as any expect(result.fields.outer.type).toBe('Object') expect(result.fields.outer.fields.inner).toEqual({ type: 'string', value: 'value' }) @@ -284,7 +289,7 @@ describe('capture', () => { it('should respect maxReferenceDepth', () => { const obj = { level1: { level2: { level3: { level4: 'deep' } } } } - const result = capture(obj, { ...defaultOpts, maxReferenceDepth: 2 }) as any + const result = capture(obj, { ...defaultOpts, maxReferenceDepth: 2 }, noTimeout()) as any expect(result.fields.level1.fields.level2.notCapturedReason).toBe('depth') }) @@ -294,7 +299,7 @@ describe('capture', () => { for (let i = 0; i < 30; i++) { obj[`field${i}`] = i } - const result = capture(obj, { ...defaultOpts, maxFieldCount: 5 }) as any + const result = capture(obj, { ...defaultOpts, maxFieldCount: 5 }, noTimeout()) as any expect(Object.keys(result.fields).length).toBe(5) expect(result.notCapturedReason).toBe('fieldCount') @@ -308,14 +313,14 @@ describe('capture', () => { } const sym = Symbol('test') const obj = { [sym]: 'value' } - const result = capture(obj, defaultOpts) as any + const result = capture(obj, defaultOpts, noTimeout()) as any expect(result.fields.test).toEqual({ type: 'string', value: 'value' }) }) it('should escape dots in field names', () => { const obj = { 'field.with.dots': 'value' } - const result = capture(obj, defaultOpts) as any + const result = capture(obj, defaultOpts, noTimeout()) as any expect(result.fields.field_with_dots).toEqual({ type: 'string', value: 'value' }) }) @@ -326,7 +331,7 @@ describe('capture', () => { throw new Error('getter error') }, } - const result = capture(obj, defaultOpts) as any + const result = capture(obj, defaultOpts, noTimeout()) as any expect(result.fields.throwing).toEqual({ type: 'undefined', @@ -339,7 +344,7 @@ describe('capture', () => { public field = 'value' } const instance = new MyClass() - const result = capture(instance, defaultOpts) as any + const result = capture(instance, defaultOpts, noTimeout()) as any expect(result.type).toBe('MyClass') expect(result.fields.field).toEqual({ type: 'string', value: 'value' }) @@ -349,28 +354,28 @@ describe('capture', () => { describe('functions', () => { it('should capture function', () => { function myFunc() {} // eslint-disable-line @typescript-eslint/no-empty-function - const result = capture(myFunc, defaultOpts) as any + const result = capture(myFunc, defaultOpts, noTimeout()) as any expect(result.type).toBe('Function') }) it('should capture class as class', () => { class MyClass {} - const result = capture(MyClass, defaultOpts) + const result = capture(MyClass, defaultOpts, noTimeout()) expect(result.type).toBe('class MyClass') }) it('should capture anonymous class', () => { const AnonymousClass = class {} - const result = capture(AnonymousClass, defaultOpts) + const result = capture(AnonymousClass, defaultOpts, noTimeout()) expect(result.type).toBe('class') }) it('should respect depth for functions', () => { function myFunc() {} // eslint-disable-line @typescript-eslint/no-empty-function - const result = capture(myFunc, { ...defaultOpts, maxReferenceDepth: 0 }) + const result = capture(myFunc, { ...defaultOpts, maxReferenceDepth: 0 }, noTimeout()) expect(result).toEqual({ type: 'Function', notCapturedReason: 'depth' }) }) @@ -379,7 +384,7 @@ describe('capture', () => { describe('binary data', () => { it('should capture ArrayBuffer', () => { const buffer = new ArrayBuffer(16) - const result = capture(buffer, defaultOpts) + const result = capture(buffer, defaultOpts, noTimeout()) expect(result).toEqual({ type: 'ArrayBuffer', @@ -393,7 +398,7 @@ describe('capture', () => { return } const buffer = new SharedArrayBuffer(16) - const result = capture(buffer, defaultOpts) + const result = capture(buffer, defaultOpts, noTimeout()) expect(result).toEqual({ type: 'SharedArrayBuffer', @@ -404,7 +409,7 @@ describe('capture', () => { it('should capture DataView', () => { const buffer = new ArrayBuffer(16) const view = new DataView(buffer, 4, 8) - const result = capture(view, defaultOpts) as any + const result = capture(view, defaultOpts, noTimeout()) as any expect(result.type).toBe('DataView') expect(result.fields.byteLength).toEqual({ type: 'number', value: '8' }) @@ -414,7 +419,7 @@ describe('capture', () => { it('should capture Uint8Array', () => { const arr = new Uint8Array([1, 2, 3]) - const result = capture(arr, defaultOpts) as any + const result = capture(arr, defaultOpts, noTimeout()) as any expect(result.type).toBe('Uint8Array') expect(result.elements).toEqual([ @@ -428,7 +433,7 @@ describe('capture', () => { it('should truncate large TypedArrays', () => { const arr = new Uint8Array(200) - const result = capture(arr, { ...defaultOpts, maxCollectionSize: 3 }) as any + const result = capture(arr, { ...defaultOpts, maxCollectionSize: 3 }, noTimeout()) as any expect(result.elements.length).toBe(3) expect(result.notCapturedReason).toBe('collectionSize') @@ -440,7 +445,7 @@ describe('capture', () => { it('should handle circular references by respecting depth limit', () => { const obj: any = { name: 'root' } obj.self = obj - const result = capture(obj, { ...defaultOpts, maxReferenceDepth: 1 }) as any + const result = capture(obj, { ...defaultOpts, maxReferenceDepth: 1 }, noTimeout()) as any expect(result.fields.name).toEqual({ type: 'string', value: 'root' }) expect(result.fields.self.notCapturedReason).toBe('depth') @@ -458,7 +463,7 @@ describe('captureFields', () => { it('should return fields directly without wrapper', () => { const obj = { a: 1, b: 'hello', c: true } - const result = captureFields(obj, defaultOpts) + const result = captureFields(obj, defaultOpts, noTimeout()) // Should be Record, not CapturedValue expect(result).toEqual({ @@ -477,7 +482,7 @@ describe('captureFields', () => { name: 'test', nested: { value: 42 }, } - const result = captureFields(obj, defaultOpts) + const result = captureFields(obj, defaultOpts, noTimeout()) expect(result).toEqual({ name: { type: 'string', value: 'test' }, @@ -492,7 +497,7 @@ describe('captureFields', () => { it('should respect maxFieldCount', () => { const obj = { a: 1, b: 2, c: 3, d: 4, e: 5 } - const result = captureFields(obj, { ...defaultOpts, maxFieldCount: 3 }) + const result = captureFields(obj, { ...defaultOpts, maxFieldCount: 3 }, noTimeout()) const keys = Object.keys(result) expect(keys.length).toBe(3) @@ -506,7 +511,7 @@ describe('captureFields', () => { }, }, } - const result = captureFields(obj, { ...defaultOpts, maxReferenceDepth: 2 }) + const result = captureFields(obj, { ...defaultOpts, maxReferenceDepth: 2 }, noTimeout()) expect(result.level1).toEqual({ type: 'Object', @@ -521,7 +526,7 @@ describe('captureFields', () => { it('should handle properties with dots in names', () => { const obj = { 'some.property': 'value' } - const result = captureFields(obj, defaultOpts) + const result = captureFields(obj, defaultOpts, noTimeout()) expect(result['some_property']).toEqual({ type: 'string', value: 'value' }) }) @@ -533,7 +538,7 @@ describe('captureFields', () => { } const sym = Symbol('test') const obj = { [sym]: 'symbolValue' } - const result = captureFields(obj, defaultOpts) + const result = captureFields(obj, defaultOpts, noTimeout()) expect(result.test).toEqual({ type: 'string', value: 'symbolValue' }) }) @@ -546,7 +551,7 @@ describe('captureFields', () => { }, enumerable: true, }) - const result = captureFields(obj, defaultOpts) + const result = captureFields(obj, defaultOpts, noTimeout()) expect(result.throwing).toEqual({ type: 'undefined', @@ -554,3 +559,89 @@ describe('captureFields', () => { }) }) }) + +describe('capture timeout', () => { + const defaultOpts = { + maxReferenceDepth: 3, + maxCollectionSize: 100, + maxFieldCount: 20, + maxLength: 255, + } + + it('should return timeout value with real type when already timed out', () => { + const ctx: CaptureContext = { deadline: 0, timedOut: true } + + expect(capture({ a: 1 }, defaultOpts, ctx)).toEqual({ type: 'object', notCapturedReason: 'timeout' }) + expect(capture('hello', defaultOpts, ctx)).toEqual({ type: 'string', notCapturedReason: 'timeout' }) + expect(capture(42, defaultOpts, ctx)).toEqual({ type: 'number', notCapturedReason: 'timeout' }) + expect(capture(null, defaultOpts, ctx)).toEqual({ type: 'null', notCapturedReason: 'timeout' }) + expect(capture(undefined, defaultOpts, ctx)).toEqual({ type: 'undefined', notCapturedReason: 'timeout' }) + }) + + it('should stop traversing object properties when deadline is exceeded', () => { + let callCount = 0 + const originalNow = performance.now.bind(performance) + spyOn(performance, 'now').and.callFake(() => { + callCount++ + // First call is the deadline check at the start of captureValue, + // subsequent calls are from isTimedOut checks during property iteration. + // Return past-deadline after a few calls to simulate timeout mid-traversal. + if (callCount <= 3) { + return originalNow() + } + return Infinity + }) + + const obj: Record = {} + for (let i = 0; i < 20; i++) { + obj[`field${i}`] = i + } + + const ctx: CaptureContext = { deadline: performance.now() + 10, timedOut: false } + const result = capture(obj, defaultOpts, ctx) + + expect(ctx.timedOut).toBe(true) + const capturedFieldCount = Object.keys((result as any).fields || {}).length + expect(capturedFieldCount).toBeLessThan(20) + }) + + it('should stop traversing array elements when deadline is exceeded', () => { + let callCount = 0 + spyOn(performance, 'now').and.callFake(() => { + callCount++ + if (callCount <= 4) { + return 100 + } + return Infinity + }) + + const arr = Array(50).fill({ nested: 'value' }) + const ctx: CaptureContext = { deadline: 200, timedOut: false } + const result = capture(arr, defaultOpts, ctx) + + expect(ctx.timedOut).toBe(true) + expect((result as any).elements.length).toBeLessThan(50) + }) + + it('should stop captureFields traversal when deadline is exceeded', () => { + let callCount = 0 + spyOn(performance, 'now').and.callFake(() => { + callCount++ + if (callCount <= 2) { + return 100 + } + return Infinity + }) + + const obj: Record = {} + for (let i = 0; i < 20; i++) { + obj[`field${i}`] = i + } + + const ctx: CaptureContext = { deadline: 200, timedOut: false } + const result = captureFields(obj, defaultOpts, ctx) + + expect(ctx.timedOut).toBe(true) + expect(Object.keys(result).length).toBeLessThan(20) + }) +}) diff --git a/packages/debugger/src/domain/capture.ts b/packages/debugger/src/domain/capture.ts index e6906f2f0e..52413cf021 100644 --- a/packages/debugger/src/domain/capture.ts +++ b/packages/debugger/src/domain/capture.ts @@ -17,6 +17,15 @@ export interface CapturedValue { entries?: Array<[CapturedValue, CapturedValue]> } +/** + * Mutable context threaded through the capture walker so that a single + * `performance.now()` deadline can cooperatively abort deep traversals. + */ +export interface CaptureContext { + deadline: number + timedOut: boolean +} + // Prefer replaceAll to replace because it's ~20% faster in this hot code path. const REGEX_PERIOD = /\./g const HAS_REPLACE_ALL = typeof (String.prototype as any).replaceAll === 'function' @@ -29,6 +38,17 @@ const DEFAULT_MAX_COLLECTION_SIZE = 100 const DEFAULT_MAX_FIELD_COUNT = 20 const DEFAULT_MAX_LENGTH = 255 +function isTimedOut(ctx: CaptureContext): boolean { + if (ctx.timedOut) { + return true + } + if (performance.now() >= ctx.deadline) { + ctx.timedOut = true + return true + } + return false +} + /** * Capture the value of the given object with configurable limits * @@ -38,6 +58,7 @@ const DEFAULT_MAX_LENGTH = 255 * @param opts.maxCollectionSize - The maximum size of collections to capture * @param opts.maxFieldCount - The maximum number of fields to capture * @param opts.maxLength - The maximum length of strings to capture + * @param ctx - Capture context with a deadline for cooperative timeout * @returns The captured value representation */ export function capture( @@ -47,9 +68,10 @@ export function capture( maxCollectionSize = DEFAULT_MAX_COLLECTION_SIZE, maxFieldCount = DEFAULT_MAX_FIELD_COUNT, maxLength = DEFAULT_MAX_LENGTH, - }: CaptureOptions + }: CaptureOptions, + ctx: CaptureContext ): CapturedValue { - return captureValue(value, 0, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + return captureValue(value, 0, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx) } /** @@ -61,6 +83,7 @@ export function capture( * @param opts.maxCollectionSize - The maximum size of collections to capture * @param opts.maxFieldCount - The maximum number of fields to capture * @param opts.maxLength - The maximum length of strings to capture + * @param ctx - Capture context with a deadline for cooperative timeout * @returns A record mapping property names to their captured values */ export function captureFields( @@ -70,9 +93,10 @@ export function captureFields( maxCollectionSize = DEFAULT_MAX_COLLECTION_SIZE, maxFieldCount = DEFAULT_MAX_FIELD_COUNT, maxLength = DEFAULT_MAX_LENGTH, - }: CaptureOptions + }: CaptureOptions, + ctx: CaptureContext ): Record { - return captureObjectPropertiesFields(obj, 0, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + return captureObjectPropertiesFields(obj, 0, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx) } function captureValue( @@ -81,8 +105,13 @@ function captureValue( maxReferenceDepth: number, maxCollectionSize: number, maxFieldCount: number, - maxLength: number + maxLength: number, + ctx: CaptureContext ): CapturedValue { + if (isTimedOut(ctx)) { + return { type: value === null ? 'null' : typeof value, notCapturedReason: 'timeout' } + } + // Handle null first as typeof null === 'object' if (value === null) { return { type: 'null', isNull: true } @@ -104,10 +133,17 @@ function captureValue( case 'bigint': return { type: 'bigint', value: String(value) } // eslint-disable-line @typescript-eslint/no-base-to-string case 'function': - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return captureFunction(value as Function, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + return captureFunction( + value as Function, // eslint-disable-line @typescript-eslint/no-unsafe-function-type + depth, + maxReferenceDepth, + maxCollectionSize, + maxFieldCount, + maxLength, + ctx + ) case 'object': - return captureObject(value as object, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + return captureObject(value as object, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx) default: return { type: String(type), notCapturedReason: 'Unsupported type' } } @@ -134,7 +170,8 @@ function captureFunction( maxReferenceDepth: number, maxCollectionSize: number, maxFieldCount: number, - maxLength: number + maxLength: number, + ctx: CaptureContext ): CapturedValue { // Check if it's a class by converting to string and checking for 'class' keyword const fnStr = Function.prototype.toString.call(fn) @@ -158,7 +195,8 @@ function captureFunction( maxReferenceDepth, maxCollectionSize, maxFieldCount, - maxLength + maxLength, + ctx ) } @@ -168,7 +206,8 @@ function captureObject( maxReferenceDepth: number, maxCollectionSize: number, maxFieldCount: number, - maxLength: number + maxLength: number, + ctx: CaptureContext ): CapturedValue { if (depth >= maxReferenceDepth) { return { type: (obj as any).constructor?.name ?? 'Object', notCapturedReason: 'depth' } @@ -186,7 +225,7 @@ function captureObject( return { type: 'RegExp', value: obj.toString() } } if (obj instanceof Error) { - return captureError(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + return captureError(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx) } if (obj instanceof Promise) { return { type: 'Promise', notCapturedReason: 'Promise state cannot be inspected' } @@ -194,13 +233,13 @@ function captureObject( // Collections if (Array.isArray(obj)) { - return captureArray(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + return captureArray(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx) } if (obj instanceof Map) { - return captureMap(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + return captureMap(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx) } if (obj instanceof Set) { - return captureSet(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + return captureSet(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx) } if (obj instanceof WeakMap) { return { type: 'WeakMap', notCapturedReason: 'WeakMap contents cannot be enumerated' } @@ -220,12 +259,29 @@ function captureObject( return captureDataView(obj) } if (ArrayBuffer.isView(obj) && !(obj instanceof DataView)) { - return captureTypedArray(obj as TypedArray, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + return captureTypedArray( + obj as TypedArray, + depth, + maxReferenceDepth, + maxCollectionSize, + maxFieldCount, + maxLength, + ctx + ) } // Custom objects const typeName = (obj as any).constructor?.name ?? 'Object' - return captureObjectProperties(obj, typeName, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + return captureObjectProperties( + obj, + typeName, + depth, + maxReferenceDepth, + maxCollectionSize, + maxFieldCount, + maxLength, + ctx + ) } function captureObjectPropertiesFields( @@ -234,7 +290,8 @@ function captureObjectPropertiesFields( maxReferenceDepth: number, maxCollectionSize: number, maxFieldCount: number, - maxLength: number + maxLength: number, + ctx: CaptureContext ): Record { const keys = Object.getOwnPropertyNames(obj) const symbolKeys = Object.getOwnPropertySymbols(obj) @@ -244,6 +301,10 @@ function captureObjectPropertiesFields( const fields: Record = {} for (const key of keysToCapture) { + if (isTimedOut(ctx)) { + break + } + const keyStr = String(key) const keyName = typeof key === 'symbol' ? key.description || key.toString() : keyStr.includes('.') ? replaceDots(keyStr) : keyStr @@ -256,7 +317,8 @@ function captureObjectPropertiesFields( maxReferenceDepth, maxCollectionSize, maxFieldCount, - maxLength + maxLength, + ctx ) } catch { // Handle getters that throw or other access errors @@ -274,7 +336,8 @@ function captureObjectProperties( maxReferenceDepth: number, maxCollectionSize: number, maxFieldCount: number, - maxLength: number + maxLength: number, + ctx: CaptureContext ): CapturedValue { const keys = Object.getOwnPropertyNames(obj) const symbolKeys = Object.getOwnPropertySymbols(obj) @@ -287,7 +350,8 @@ function captureObjectProperties( maxReferenceDepth, maxCollectionSize, maxFieldCount, - maxLength + maxLength, + ctx ) const result: CapturedValue = { type: typeName, fields } @@ -306,14 +370,18 @@ function captureArray( maxReferenceDepth: number, maxCollectionSize: number, maxFieldCount: number, - maxLength: number + maxLength: number, + ctx: CaptureContext ): CapturedValue { const totalSize = arr.length const itemsToCapture = Math.min(totalSize, maxCollectionSize) const elements: CapturedValue[] = [] for (let i = 0; i < itemsToCapture; i++) { - elements.push(captureValue(arr[i], depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength)) + if (isTimedOut(ctx)) { + break + } + elements.push(captureValue(arr[i], depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx)) } const result: CapturedValue = { type: 'Array', elements } @@ -332,7 +400,8 @@ function captureMap( maxReferenceDepth: number, maxCollectionSize: number, maxFieldCount: number, - maxLength: number + maxLength: number, + ctx: CaptureContext ): CapturedValue { const totalSize = map.size const entriesToCapture = Math.min(totalSize, maxCollectionSize) @@ -340,12 +409,12 @@ function captureMap( const entries: Array<[CapturedValue, CapturedValue]> = [] let count = 0 for (const [key, value] of map) { - if (count >= entriesToCapture) { + if (count >= entriesToCapture || isTimedOut(ctx)) { break } entries.push([ - captureValue(key, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength), - captureValue(value, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength), + captureValue(key, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx), + captureValue(value, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx), ]) count++ } @@ -366,7 +435,8 @@ function captureSet( maxReferenceDepth: number, maxCollectionSize: number, maxFieldCount: number, - maxLength: number + maxLength: number, + ctx: CaptureContext ): CapturedValue { const totalSize = set.size const itemsToCapture = Math.min(totalSize, maxCollectionSize) @@ -374,10 +444,10 @@ function captureSet( const elements: CapturedValue[] = [] let count = 0 for (const value of set) { - if (count >= itemsToCapture) { + if (count >= itemsToCapture || isTimedOut(ctx)) { break } - elements.push(captureValue(value, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength)) + elements.push(captureValue(value, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx)) count++ } @@ -397,16 +467,25 @@ function captureError( maxReferenceDepth: number, maxCollectionSize: number, maxFieldCount: number, - maxLength: number + maxLength: number, + ctx: CaptureContext ): CapturedValue { const typeName = (err as any).constructor?.name ?? 'Error' const fields: Record = { - message: captureValue(err.message, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength), - name: captureValue(err.name, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength), + message: captureValue(err.message, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx), + name: captureValue(err.name, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx), } if (err.stack !== undefined) { - fields.stack = captureValue(err.stack, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + fields.stack = captureValue( + err.stack, + depth + 1, + maxReferenceDepth, + maxCollectionSize, + maxFieldCount, + maxLength, + ctx + ) } if ((err as any).cause !== undefined) { @@ -416,7 +495,8 @@ function captureError( maxReferenceDepth, maxCollectionSize, maxFieldCount, - maxLength + maxLength, + ctx ) } @@ -467,7 +547,8 @@ function captureTypedArray( maxReferenceDepth: number, maxCollectionSize: number, maxFieldCount: number, - maxLength: number + maxLength: number, + ctx: CaptureContext ): CapturedValue { const typeName = typedArray.constructor?.name ?? 'TypedArray' const totalSize = typedArray.length @@ -475,8 +556,11 @@ function captureTypedArray( const elements: CapturedValue[] = [] for (let i = 0; i < itemsToCapture; i++) { + if (isTimedOut(ctx)) { + break + } elements.push( - captureValue(typedArray[i], depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + captureValue(typedArray[i], depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx) ) }