diff --git a/packages/utils/src/lib/exit-process.int.test.ts b/packages/utils/src/lib/exit-process.int.test.ts new file mode 100644 index 000000000..7f3d0850a --- /dev/null +++ b/packages/utils/src/lib/exit-process.int.test.ts @@ -0,0 +1,129 @@ +import process from 'node:process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SIGNAL_EXIT_CODES, installExitHandlers } from './exit-process.js'; + +describe('installExitHandlers', () => { + const onError = vi.fn(); + const onExit = vi.fn(); + const processOnSpy = vi.spyOn(process, 'on'); + const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(vi.fn()); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + [ + 'uncaughtException', + 'unhandledRejection', + 'SIGINT', + 'SIGTERM', + 'SIGQUIT', + 'exit', + ].forEach(event => { + process.removeAllListeners(event); + }); + }); + + it('should install event listeners for all expected events', () => { + expect(() => installExitHandlers({ onError, onExit })).not.toThrow(); + + expect(processOnSpy).toHaveBeenCalledWith( + 'uncaughtException', + expect.any(Function), + ); + expect(processOnSpy).toHaveBeenCalledWith( + 'unhandledRejection', + expect.any(Function), + ); + expect(processOnSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('SIGQUIT', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + }); + + it('should call onError with error and kind for uncaughtException', () => { + expect(() => installExitHandlers({ onError })).not.toThrow(); + + const testError = new Error('Test uncaught exception'); + + (process as any).emit('uncaughtException', testError); + + expect(onError).toHaveBeenCalledWith(testError, 'uncaughtException'); + expect(onError).toHaveBeenCalledTimes(1); + expect(onExit).not.toHaveBeenCalled(); + }); + + it('should call onError with reason and kind for unhandledRejection', () => { + expect(() => installExitHandlers({ onError })).not.toThrow(); + + const testReason = 'Test unhandled rejection'; + + (process as any).emit('unhandledRejection', testReason); + + expect(onError).toHaveBeenCalledWith(testReason, 'unhandledRejection'); + expect(onError).toHaveBeenCalledTimes(1); + expect(onExit).not.toHaveBeenCalled(); + }); + + it('should call onExit and exit with code 0 for SIGINT', () => { + expect(() => installExitHandlers({ onExit })).not.toThrow(); + + (process as any).emit('SIGINT'); + + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { + kind: 'signal', + signal: 'SIGINT', + }); + expect(onError).not.toHaveBeenCalled(); + }); + + it('should call onExit and exit with code 0 for SIGTERM', () => { + expect(() => installExitHandlers({ onExit })).not.toThrow(); + + (process as any).emit('SIGTERM'); + + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM, { + kind: 'signal', + signal: 'SIGTERM', + }); + expect(onError).not.toHaveBeenCalled(); + }); + + it('should call onExit and exit with code 0 for SIGQUIT', () => { + expect(() => installExitHandlers({ onExit })).not.toThrow(); + + (process as any).emit('SIGQUIT'); + + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGQUIT, { + kind: 'signal', + signal: 'SIGQUIT', + }); + expect(onError).not.toHaveBeenCalled(); + }); + + it('should call onExit for successful process termination with exit code 0', () => { + expect(() => installExitHandlers({ onExit })).not.toThrow(); + + (process as any).emit('exit', 0); + + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(0, { kind: 'exit' }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should call onExit for failed process termination with exit code 1', () => { + expect(() => installExitHandlers({ onExit })).not.toThrow(); + + (process as any).emit('exit', 1); + + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(1, { kind: 'exit' }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/utils/src/lib/exit-process.ts b/packages/utils/src/lib/exit-process.ts new file mode 100644 index 000000000..62cee4977 --- /dev/null +++ b/packages/utils/src/lib/exit-process.ts @@ -0,0 +1,96 @@ +import os from 'node:os'; +import process from 'node:process'; + +// POSIX shells convention: exit status = 128 + signal number +// https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html#:~:text=When%20a%20command%20terminates%20on%20a%20fatal%20signal%20whose%20number%20is%20N%2C%20Bash%20uses%20the%20value%20128%2BN%20as%20the%20exit%20status. +const UNIX_SIGNAL_EXIT_CODE_OFFSET = 128; +const unixSignalExitCode = (signalNumber: number) => + UNIX_SIGNAL_EXIT_CODE_OFFSET + signalNumber; + +const SIGINT_CODE = 2; +const SIGTERM_CODE = 15; +const SIGQUIT_CODE = 3; + +export const SIGNAL_EXIT_CODES = (): Record => { + const isWindowsRuntime = os.platform() === 'win32'; + return { + SIGINT: isWindowsRuntime ? SIGINT_CODE : unixSignalExitCode(SIGINT_CODE), + SIGTERM: unixSignalExitCode(SIGTERM_CODE), + SIGQUIT: unixSignalExitCode(SIGQUIT_CODE), + }; +}; + +export const DEFAULT_FATAL_EXIT_CODE = 1; + +export type SignalName = 'SIGINT' | 'SIGTERM' | 'SIGQUIT'; +export type FatalKind = 'uncaughtException' | 'unhandledRejection'; + +export type CloseReason = + | { kind: 'signal'; signal: SignalName } + | { kind: 'fatal'; fatal: FatalKind } + | { kind: 'exit' }; + +export type ExitHandlerOptions = { + onExit?: (code: number, reason: CloseReason) => void; + onError?: (err: unknown, kind: FatalKind) => void; + exitOnFatal?: boolean; + exitOnSignal?: boolean; + fatalExitCode?: number; +}; + +export function installExitHandlers(options: ExitHandlerOptions = {}): void { + // eslint-disable-next-line functional/no-let + let closedReason: CloseReason | undefined; + const { + onExit, + onError, + exitOnFatal, + exitOnSignal, + fatalExitCode = DEFAULT_FATAL_EXIT_CODE, + } = options; + + const close = (code: number, reason: CloseReason) => { + if (closedReason) { + return; + } + closedReason = reason; + onExit?.(code, reason); + }; + + process.on('uncaughtException', err => { + onError?.(err, 'uncaughtException'); + if (exitOnFatal) { + close(fatalExitCode, { + kind: 'fatal', + fatal: 'uncaughtException', + }); + } + }); + + process.on('unhandledRejection', reason => { + onError?.(reason, 'unhandledRejection'); + if (exitOnFatal) { + close(fatalExitCode, { + kind: 'fatal', + fatal: 'unhandledRejection', + }); + } + }); + + (['SIGINT', 'SIGTERM', 'SIGQUIT'] as const).forEach(signal => { + process.on(signal, () => { + close(SIGNAL_EXIT_CODES()[signal], { kind: 'signal', signal }); + if (exitOnSignal) { + // eslint-disable-next-line n/no-process-exit + process.exit(SIGNAL_EXIT_CODES()[signal]); + } + }); + }); + + process.on('exit', code => { + if (closedReason) { + return; + } + close(code, { kind: 'exit' }); + }); +} diff --git a/packages/utils/src/lib/exit-process.unit.test.ts b/packages/utils/src/lib/exit-process.unit.test.ts new file mode 100644 index 000000000..d9437a51c --- /dev/null +++ b/packages/utils/src/lib/exit-process.unit.test.ts @@ -0,0 +1,262 @@ +import os from 'node:os'; +import process from 'node:process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SIGNAL_EXIT_CODES, installExitHandlers } from './exit-process.js'; + +describe('exit-process tests', () => { + const onError = vi.fn(); + const onExit = vi.fn(); + const processOnSpy = vi.spyOn(process, 'on'); + const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(vi.fn()); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + [ + 'uncaughtException', + 'unhandledRejection', + 'SIGINT', + 'SIGTERM', + 'SIGQUIT', + 'exit', + ].forEach(event => { + process.removeAllListeners(event); + }); + }); + + it('should install event listeners for all expected events', () => { + expect(() => installExitHandlers({ onError, onExit })).not.toThrow(); + + expect(processOnSpy).toHaveBeenCalledWith( + 'uncaughtException', + expect.any(Function), + ); + expect(processOnSpy).toHaveBeenCalledWith( + 'unhandledRejection', + expect.any(Function), + ); + expect(processOnSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('SIGQUIT', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + }); + + it('should call onError with error and kind for uncaughtException', () => { + expect(() => installExitHandlers({ onError })).not.toThrow(); + + const testError = new Error('Test uncaught exception'); + + (process as any).emit('uncaughtException', testError); + + expect(onError).toHaveBeenCalledWith(testError, 'uncaughtException'); + expect(onError).toHaveBeenCalledTimes(1); + expect(onExit).not.toHaveBeenCalled(); + }); + + it('should call onError with reason and kind for unhandledRejection', () => { + expect(() => installExitHandlers({ onError })).not.toThrow(); + + const testReason = 'Test unhandled rejection'; + + (process as any).emit('unhandledRejection', testReason); + + expect(onError).toHaveBeenCalledWith(testReason, 'unhandledRejection'); + expect(onError).toHaveBeenCalledTimes(1); + expect(onExit).not.toHaveBeenCalled(); + }); + + it('should call onExit with correct code and reason for SIGINT', () => { + expect(() => + installExitHandlers({ onExit, exitOnSignal: true }), + ).not.toThrow(); + + (process as any).emit('SIGINT'); + + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { + kind: 'signal', + signal: 'SIGINT', + }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT); + }); + + it('should call onExit with correct code and reason for SIGTERM', () => { + expect(() => + installExitHandlers({ onExit, exitOnSignal: true }), + ).not.toThrow(); + + (process as any).emit('SIGTERM'); + + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM, { + kind: 'signal', + signal: 'SIGTERM', + }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM); + }); + + it('should call onExit with correct code and reason for SIGQUIT', () => { + expect(() => + installExitHandlers({ onExit, exitOnSignal: true }), + ).not.toThrow(); + + (process as any).emit('SIGQUIT'); + + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGQUIT, { + kind: 'signal', + signal: 'SIGQUIT', + }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGQUIT); + }); + + it('should not exit process when exitOnSignal is false', () => { + expect(() => + installExitHandlers({ onExit, exitOnSignal: false }), + ).not.toThrow(); + + (process as any).emit('SIGINT'); + + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { + kind: 'signal', + signal: 'SIGINT', + }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should not exit process when exitOnSignal is not set', () => { + expect(() => installExitHandlers({ onExit })).not.toThrow(); + + (process as any).emit('SIGTERM'); + + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM, { + kind: 'signal', + signal: 'SIGTERM', + }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should call onExit with exit code and reason for normal exit', () => { + expect(() => installExitHandlers({ onExit })).not.toThrow(); + + const exitCode = 42; + (process as any).emit('exit', exitCode); + + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(exitCode, { kind: 'exit' }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should call onExit with fatal reason when exitOnFatal is true', () => { + expect(() => + installExitHandlers({ onError, onExit, exitOnFatal: true }), + ).not.toThrow(); + + const testError = new Error('Test uncaught exception'); + + (process as any).emit('uncaughtException', testError); + + expect(onError).toHaveBeenCalledWith(testError, 'uncaughtException'); + expect(onError).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(1, { + kind: 'fatal', + fatal: 'uncaughtException', + }); + expect(onExit).toHaveBeenCalledTimes(1); + }); + + it('should use custom fatalExitCode when exitOnFatal is true', () => { + expect(() => + installExitHandlers({ + onError, + onExit, + exitOnFatal: true, + fatalExitCode: 42, + }), + ).not.toThrow(); + + const testError = new Error('Test uncaught exception'); + + (process as any).emit('uncaughtException', testError); + + expect(onError).toHaveBeenCalledWith(testError, 'uncaughtException'); + expect(onError).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(42, { + kind: 'fatal', + fatal: 'uncaughtException', + }); + expect(onExit).toHaveBeenCalledTimes(1); + }); + + it('should call onExit with fatal reason for unhandledRejection when exitOnFatal is true', () => { + expect(() => + installExitHandlers({ onError, onExit, exitOnFatal: true }), + ).not.toThrow(); + + const testReason = 'Test unhandled rejection'; + + (process as any).emit('unhandledRejection', testReason); + + expect(onError).toHaveBeenCalledWith(testReason, 'unhandledRejection'); + expect(onError).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(1, { + kind: 'fatal', + fatal: 'unhandledRejection', + }); + expect(onExit).toHaveBeenCalledTimes(1); + }); + + it('should have correct SIGINT exit code on Windows', () => { + const osSpy = vi.spyOn(os, 'platform').mockReturnValue('win32'); + const exitCodes = SIGNAL_EXIT_CODES(); + expect(exitCodes.SIGINT).toBe(2); + osSpy.mockRestore(); + }); + + it('should have correct SIGINT exit code on Unix-like systems', () => { + const osSpy = vi.spyOn(os, 'platform').mockReturnValue('linux'); + const exitCodes = SIGNAL_EXIT_CODES(); + expect(exitCodes.SIGINT).toBe(130); + osSpy.mockRestore(); + }); + + it('should calculate Windows exit codes correctly when platform is mocked to Windows', () => { + const osSpy = vi.spyOn(os, 'platform').mockReturnValue('win32'); + + const exitCodes = SIGNAL_EXIT_CODES(); + + expect(exitCodes.SIGINT).toBe(2); // SIGINT_CODE = 2 on Windows + expect(exitCodes.SIGTERM).toBe(143); // 128 + 15 = 143 + expect(exitCodes.SIGQUIT).toBe(131); // 128 + 3 = 131 + + osSpy.mockRestore(); + }); + + it('should call onExit only once even when close is called multiple times', () => { + expect(() => + installExitHandlers({ onExit, exitOnSignal: true }), + ).not.toThrow(); + + (process as any).emit('SIGINT'); + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { + kind: 'signal', + signal: 'SIGINT', + }); + onExit.mockClear(); + (process as any).emit('SIGTERM'); + expect(onExit).not.toHaveBeenCalled(); + (process as any).emit('exit', 0); + expect(onExit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/utils/src/lib/logger.ts b/packages/utils/src/lib/logger.ts index b607c5e79..0051bfabb 100644 --- a/packages/utils/src/lib/logger.ts +++ b/packages/utils/src/lib/logger.ts @@ -1,11 +1,11 @@ /* eslint-disable max-lines, no-console, @typescript-eslint/class-methods-use-this */ import ansis, { type AnsiColors } from 'ansis'; -import os from 'node:os'; import ora, { type Ora } from 'ora'; import { formatCommandStatus } from './command.js'; import { dateToUnixTimestamp } from './dates.js'; import { isEnvVarEnabled } from './env.js'; import { stringifyError } from './errors.js'; +import { SIGNAL_EXIT_CODES } from './exit-process.js'; import { formatDuration, indentLines, transformLines } from './formatting.js'; import { settlePromise } from './promises.js'; @@ -28,12 +28,6 @@ export type DebugLogOptions = LogOptions & { const HEX_RADIX = 16; -const SIGINT_CODE = 2; -// https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html#:~:text=When%20a%20command%20terminates%20on%20a%20fatal%20signal%20whose%20number%20is%20N%2C%20Bash%20uses%20the%20value%20128%2BN%20as%20the%20exit%20status. -const SIGNALS_CODE_OFFSET_UNIX = 128; -const SIGINT_EXIT_CODE_UNIX = SIGNALS_CODE_OFFSET_UNIX + SIGINT_CODE; -const SIGINT_EXIT_CODE_WINDOWS = SIGINT_CODE; - /** * Rich logging implementation for Code PushUp CLI, plugins, etc. * @@ -77,11 +71,7 @@ export class Logger { this.newline(); this.error(ansis.bold('Cancelled by SIGINT')); // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit - process.exit( - os.platform() === 'win32' - ? SIGINT_EXIT_CODE_WINDOWS - : SIGINT_EXIT_CODE_UNIX, - ); + process.exit(SIGNAL_EXIT_CODES().SIGINT); }; /** diff --git a/packages/utils/src/lib/performance-observer.int.test.ts b/packages/utils/src/lib/performance-observer.int.test.ts index 8209dc71f..2c1721ebb 100644 --- a/packages/utils/src/lib/performance-observer.int.test.ts +++ b/packages/utils/src/lib/performance-observer.int.test.ts @@ -179,4 +179,49 @@ describe('PerformanceObserverSink', () => { expect(sink.getWrittenItems()).toHaveLength(2); }); + + it('cursor logic prevents duplicate processing of performance entries', () => { + const observer = new PerformanceObserverSink(options); + observer.subscribe(); + + performance.mark('first-mark'); + performance.mark('second-mark'); + expect(encode).not.toHaveBeenCalled(); + observer.flush(); + expect(sink.getWrittenItems()).toStrictEqual([ + 'first-mark:mark', + 'second-mark:mark', + ]); + + expect(encode).toHaveBeenCalledTimes(2); + expect(encode).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ name: 'first-mark' }), + ); + expect(encode).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ name: 'second-mark' }), + ); + + performance.mark('third-mark'); + performance.measure('first-measure'); + + observer.flush(); + expect(sink.getWrittenItems()).toStrictEqual([ + 'first-mark:mark', + 'second-mark:mark', + 'third-mark:mark', + 'first-measure:measure', + ]); + + expect(encode).toHaveBeenCalledTimes(4); + expect(encode).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ name: 'third-mark' }), + ); + expect(encode).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ name: 'first-measure' }), + ); + }); }); diff --git a/packages/utils/src/lib/performance-observer.ts b/packages/utils/src/lib/performance-observer.ts index 6b360d0da..e3f72baaf 100644 --- a/packages/utils/src/lib/performance-observer.ts +++ b/packages/utils/src/lib/performance-observer.ts @@ -1,5 +1,4 @@ import { - type EntryType, type PerformanceEntry, PerformanceObserver, type PerformanceObserverEntryList, @@ -7,6 +6,8 @@ import { } from 'node:perf_hooks'; import type { Buffered, Encoder, Observer, Sink } from './sink-source.types.js'; +const OBSERVED_TYPES = ['mark', 'measure'] as const; +type ObservedEntryType = 'mark' | 'measure'; export const DEFAULT_FLUSH_THRESHOLD = 20; export type PerformanceObserverOptions = { @@ -24,16 +25,21 @@ export class PerformanceObserverSink #flushThreshold: number; #sink: Sink; #observer: PerformanceObserver | undefined; - #observedTypes: EntryType[] = ['mark', 'measure']; - #getEntries = (list: PerformanceObserverEntryList) => - this.#observedTypes.flatMap(t => list.getEntriesByType(t)); - #observedCount: number = 0; + + #pendingCount = 0; + + // "cursor" per type: how many we already wrote from the global buffer + #written: Map; constructor(options: PerformanceObserverOptions) { - this.#encode = options.encode; - this.#sink = options.sink; - this.#buffered = options.buffered ?? false; - this.#flushThreshold = options.flushThreshold ?? DEFAULT_FLUSH_THRESHOLD; + const { encode, sink, buffered, flushThreshold } = options; + this.#encode = encode; + this.#written = new Map( + OBSERVED_TYPES.map(t => [t, 0]), + ); + this.#sink = sink; + this.#buffered = buffered ?? false; + this.#flushThreshold = flushThreshold ?? DEFAULT_FLUSH_THRESHOLD; } encode(entry: PerformanceEntry): T[] { @@ -45,37 +51,51 @@ export class PerformanceObserverSink return; } - this.#observer = new PerformanceObserver(list => { - const entries = this.#getEntries(list); - this.#observedCount += entries.length; - if (this.#observedCount >= this.#flushThreshold) { - this.flush(entries); - } - }); + // The only used to trigger the flush it is not processing the entries just counting them + this.#observer = new PerformanceObserver( + (list: PerformanceObserverEntryList) => { + const batchCount = OBSERVED_TYPES.reduce( + (n, t) => n + list.getEntriesByType(t).length, + 0, + ); + + this.#pendingCount += batchCount; + if (this.#pendingCount >= this.#flushThreshold) { + this.flush(); + } + }, + ); this.#observer.observe({ - entryTypes: this.#observedTypes, + entryTypes: OBSERVED_TYPES, buffered: this.#buffered, }); } - flush(entriesToProcess?: PerformanceEntry[]): void { + flush(): void { if (!this.#observer) { return; } - const entries = entriesToProcess || this.#getEntries(performance); - entries.forEach(entry => { - const encoded = this.encode(entry); - encoded.forEach(item => { - this.#sink.write(item); - }); - }); + OBSERVED_TYPES.forEach(t => { + const written = this.#written.get(t) ?? 0; + const fresh = performance.getEntriesByType(t).slice(written); + + try { + fresh + .flatMap(entry => this.encode(entry)) + .forEach(item => this.#sink.write(item)); - // In real PerformanceObserver, entries remain in the global buffer - // They are only cleared when explicitly requested via performance.clearMarks/clearMeasures + this.#written.set(t, written + fresh.length); + } catch (error) { + throw new Error( + 'PerformanceObserverSink failed to write items to sink.', + { cause: error }, + ); + } + }); - this.#observedCount = 0; + this.#pendingCount = 0; } unsubscribe(): void { diff --git a/packages/utils/src/lib/performance-observer.unit.test.ts b/packages/utils/src/lib/performance-observer.unit.test.ts index 1e2e18287..a73be955a 100644 --- a/packages/utils/src/lib/performance-observer.unit.test.ts +++ b/packages/utils/src/lib/performance-observer.unit.test.ts @@ -41,6 +41,40 @@ describe('PerformanceObserverSink', () => { expect(MockPerformanceObserver.instances).toHaveLength(0); }); + it('creates instance with default flushThreshold when not provided', () => { + expect( + () => + new PerformanceObserverSink({ + sink, + encode, + }), + ).not.toThrow(); + expect(MockPerformanceObserver.instances).toHaveLength(0); + // Instance creation covers the default flushThreshold assignment + }); + + it('automatically flushes when pendingCount reaches flushThreshold', () => { + const observer = new PerformanceObserverSink({ + sink, + encode, + flushThreshold: 2, // Set threshold to 2 + }); + observer.subscribe(); + + const mockObserver = MockPerformanceObserver.lastInstance(); + + // Emit 1 entry - should not trigger flush yet (pendingCount = 1 < 2) + mockObserver?.emitMark('first-mark'); + expect(sink.getWrittenItems()).toStrictEqual([]); + + // Emit 1 more entry - should trigger flush (pendingCount = 2 >= 2) + mockObserver?.emitMark('second-mark'); + expect(sink.getWrittenItems()).toStrictEqual([ + 'first-mark:mark', + 'second-mark:mark', + ]); + }); + it('creates instance with all options without starting to observe', () => { expect( () => @@ -219,4 +253,56 @@ describe('PerformanceObserverSink', () => { expect(perfObserver?.disconnect).toHaveBeenCalledTimes(1); expect(MockPerformanceObserver.instances).toHaveLength(0); }); + + it('flush wraps sink write errors with descriptive error message', () => { + const failingSink = { + write: vi.fn(() => { + throw new Error('Sink write failed'); + }), + }; + + const observer = new PerformanceObserverSink({ + sink: failingSink as any, + encode, + flushThreshold: 1, + }); + + observer.subscribe(); + + performance.mark('test-mark'); + + expect(() => observer.flush()).toThrow( + expect.objectContaining({ + message: 'PerformanceObserverSink failed to write items to sink.', + cause: expect.objectContaining({ + message: 'Sink write failed', + }), + }), + ); + }); + + it('flush wraps encode errors with descriptive error message', () => { + const failingEncode = vi.fn(() => { + throw new Error('Encode failed'); + }); + + const observer = new PerformanceObserverSink({ + sink, + encode: failingEncode, + flushThreshold: 1, + }); + + observer.subscribe(); + + performance.mark('test-mark'); + + expect(() => observer.flush()).toThrow( + expect.objectContaining({ + message: 'PerformanceObserverSink failed to write items to sink.', + cause: expect.objectContaining({ + message: 'Encode failed', + }), + }), + ); + }); });