mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
Merge master, dedupe the kept copy, and retire superseded switches
Master had moved ten commits ahead. Two conflicts, both resolved without losing either side: the `XTREAM_PROBE_URL` handler this PR extracted into `events/stream-probe.ts` stays extracted (master added performance capture nearby but never touched that handler), and the mock-server scenario table takes the union of master's `performance` row and this branch's `multisrc` pair. Two findings fixed alongside it. Pinning the copy the route is already on makes discovery return that very row, so prepending the current source listed one stream twice — a phantom copy in the grouping and a chip that counted it. And handing the "playing" badge back to the route row left an in-flight switch valid, so a slow resolution could arrive afterwards and replace the playback the user had just started with Play, Resume or Restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
9f0a8b4f19
385 files changed
+58306
-2264
No files matched your search
@@ -19,6 +19,7 @@ export * from './lib/ipc-commands';
|
||||
export * from './lib/language.enum';
|
||||
export * from './lib/m3u-favorite-channel.interface';
|
||||
export * from './lib/parsed-playlist.interface';
|
||||
export * from './lib/performance-phase.interface';
|
||||
export * from './lib/playback-position.interface';
|
||||
export * from './lib/playlist-auto-update.interface';
|
||||
export * from './lib/playlist-backup.interface';
|
||||
@@ -54,6 +55,7 @@ export * from './lib/xtream-response.interface';
|
||||
export * from './lib/xtream-portal.utils';
|
||||
export * from './lib/xtream-restore-state.util';
|
||||
export * from './lib/xtream-recently-added.utils';
|
||||
export * from './lib/xtream-performance-marker.interface';
|
||||
export * from './lib/xtream-serie-details.interface';
|
||||
export * from './lib/xtream-serie-item.interface';
|
||||
export * from './lib/xtream-vod-details.interface';
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
export const PERFORMANCE_PHASE_BOUNDARY = {
|
||||
END: 'end',
|
||||
START: 'start',
|
||||
} as const;
|
||||
|
||||
export type PerformancePhaseBoundary =
|
||||
(typeof PERFORMANCE_PHASE_BOUNDARY)[keyof typeof PERFORMANCE_PHASE_BOUNDARY];
|
||||
|
||||
export const M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL =
|
||||
'IPTVNATOR_M3U_IMPORT_PERFORMANCE_PHASE_EVENT';
|
||||
|
||||
export const M3U_IMPORT_PERFORMANCE_PHASE = {
|
||||
DATA_ACQUIRE: 'data.acquire',
|
||||
NORMALIZE: 'normalize',
|
||||
PARSE_M3U: 'parse.m3u',
|
||||
} as const;
|
||||
|
||||
export type M3uImportPerformancePhase =
|
||||
(typeof M3U_IMPORT_PERFORMANCE_PHASE)[keyof typeof M3U_IMPORT_PERFORMANCE_PHASE];
|
||||
|
||||
export const APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE = {
|
||||
SERIALIZE_PLAYLIST: 'serialize.playlist',
|
||||
SQLITE_WRITE: 'sqlite.write',
|
||||
} as const;
|
||||
|
||||
export type AppPlaylistUpsertPerformancePhase =
|
||||
(typeof APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE)[keyof typeof APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE];
|
||||
|
||||
export const APP_PLAYLIST_GET_PERFORMANCE_PHASE = {
|
||||
DESERIALIZE_PLAYLIST: 'deserialize.playlist',
|
||||
SQLITE_READ: 'sqlite.read',
|
||||
} as const;
|
||||
|
||||
export type AppPlaylistGetPerformancePhase =
|
||||
(typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE)[keyof typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE];
|
||||
|
||||
export type AppPlaylistPerformancePhase =
|
||||
AppPlaylistGetPerformancePhase | AppPlaylistUpsertPerformancePhase;
|
||||
|
||||
export const XTREAM_DATABASE_PERFORMANCE_PHASE = {
|
||||
NORMALIZE_CATEGORIES: 'normalize.categories',
|
||||
NORMALIZE_CONTENT: 'normalize.content',
|
||||
NORMALIZE_SEARCH_RANK: 'normalize.search-rank',
|
||||
SQLITE_CATEGORIES_READ: 'sqlite.categories.read',
|
||||
SQLITE_CATEGORIES_WRITE_TRANSACTIONS:
|
||||
'sqlite.categories.write-transactions',
|
||||
SQLITE_CONTENT_CATEGORY_MAP_READ: 'sqlite.content.category-map-read',
|
||||
SQLITE_CONTENT_READ: 'sqlite.content.read',
|
||||
SQLITE_CONTENT_WRITE_TRANSACTIONS: 'sqlite.content.write-transactions',
|
||||
SQLITE_PLAYLIST_DELETE_COLLECT_IDS: 'sqlite.playlist-delete.collect-ids',
|
||||
SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS:
|
||||
'sqlite.playlist-delete.write-transactions',
|
||||
SQLITE_SEARCH_QUERY: 'sqlite.search.query',
|
||||
SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS:
|
||||
'sqlite.xtream-cache-clear.write-transactions',
|
||||
SQLITE_XTREAM_DELETE_COLLECT_USER_DATA:
|
||||
'sqlite.xtream-delete.collect-user-data',
|
||||
SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS:
|
||||
'sqlite.xtream-delete.write-transactions',
|
||||
} as const;
|
||||
|
||||
export type XtreamDatabasePerformancePhase =
|
||||
(typeof XTREAM_DATABASE_PERFORMANCE_PHASE)[keyof typeof XTREAM_DATABASE_PERFORMANCE_PHASE];
|
||||
|
||||
export type DatabaseWorkerPerformancePhase =
|
||||
AppPlaylistPerformancePhase | XtreamDatabasePerformancePhase;
|
||||
|
||||
export interface PerformancePhaseMetadata {
|
||||
readonly byteCount?: number;
|
||||
readonly itemCount?: number;
|
||||
}
|
||||
|
||||
export interface PerformancePhaseEvent<TPhase extends string = string> {
|
||||
readonly boundary: PerformancePhaseBoundary;
|
||||
readonly durationMs: number | null;
|
||||
readonly epochMs: number;
|
||||
readonly metadata?: PerformancePhaseMetadata;
|
||||
readonly phase: TPhase;
|
||||
readonly requestId: string;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { XtreamCodeActions } from './xtream-code-actions';
|
||||
|
||||
export const XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL =
|
||||
'IPTVNATOR_XTREAM_MAIN_PERFORMANCE_PHASE_EVENT';
|
||||
|
||||
export const XTREAM_MAIN_PERFORMANCE_PHASE = {
|
||||
CANCEL_SESSION: 'xtream.cancel-session',
|
||||
JSON_TRANSFORM: 'xtream.json.transform',
|
||||
NETWORK_TOTAL: 'xtream.network.total',
|
||||
RESPONSE_READY: 'xtream.response.ready',
|
||||
} as const;
|
||||
|
||||
export type XtreamMainPerformancePhase =
|
||||
(typeof XTREAM_MAIN_PERFORMANCE_PHASE)[keyof typeof XTREAM_MAIN_PERFORMANCE_PHASE];
|
||||
|
||||
export const XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL =
|
||||
'IPTVNATOR_XTREAM_PERF_CAPTURE_MARKER';
|
||||
|
||||
export const XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export const XTREAM_PRELOAD_PERFORMANCE_METHOD = {
|
||||
DB_CANCEL_OPERATION: 'dbCancelOperation',
|
||||
DB_DELETE_PLAYLIST: 'dbDeletePlaylist',
|
||||
DB_DELETE_XTREAM_CONTENT: 'dbDeleteXtreamContent',
|
||||
DB_GET_CATEGORIES: 'dbGetCategories',
|
||||
DB_GET_CONTENT: 'dbGetContent',
|
||||
DB_GLOBAL_SEARCH: 'dbGlobalSearch',
|
||||
DB_RESTORE_XTREAM_USER_DATA: 'dbRestoreXtreamUserData',
|
||||
DB_SAVE_CATEGORIES: 'dbSaveCategories',
|
||||
DB_SAVE_CONTENT: 'dbSaveContent',
|
||||
DB_SEARCH_CONTENT: 'dbSearchContent',
|
||||
XTREAM_CANCEL_SESSION: 'xtreamCancelSession',
|
||||
XTREAM_REQUEST: 'xtreamRequest',
|
||||
} as const;
|
||||
|
||||
export type XtreamPreloadPerformanceMethod =
|
||||
(typeof XTREAM_PRELOAD_PERFORMANCE_METHOD)[keyof typeof XTREAM_PRELOAD_PERFORMANCE_METHOD];
|
||||
|
||||
export type XtreamPreloadPerformanceBoundary = 'start' | 'end';
|
||||
export type XtreamPreloadPerformanceOutcome = 'success' | 'error' | null;
|
||||
export type XtreamPreloadPerformanceCategoryType = 'live' | 'movies' | 'series';
|
||||
export type XtreamPreloadPerformanceContentType = 'live' | 'movie' | 'series';
|
||||
|
||||
export interface XtreamPreloadPerformanceMarker {
|
||||
schemaVersion: typeof XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION;
|
||||
sourceEpochMs: number;
|
||||
ipcCallId: number;
|
||||
method: XtreamPreloadPerformanceMethod;
|
||||
boundary: XtreamPreloadPerformanceBoundary;
|
||||
outcome: XtreamPreloadPerformanceOutcome;
|
||||
playlistId: string | null;
|
||||
operationId: string | null;
|
||||
sessionId: string | null;
|
||||
action: XtreamCodeActions | null;
|
||||
categoryType: XtreamPreloadPerformanceCategoryType | null;
|
||||
contentType: XtreamPreloadPerformanceContentType | null;
|
||||
itemCount: number | null;
|
||||
}
|
||||
@@ -3,3 +3,16 @@ export {
|
||||
redactSensitiveData,
|
||||
} from './lib/redact-sensitive-data';
|
||||
export type { RedactionOptions } from './lib/redact-sensitive-data';
|
||||
export {
|
||||
measureRendererPerformancePhase,
|
||||
RENDERER_PERFORMANCE_PHASE,
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
} from './lib/renderer-performance-phase';
|
||||
export type {
|
||||
RendererPerformancePhase,
|
||||
RendererPerformancePhaseBoundary,
|
||||
RendererPerformancePhaseEvent,
|
||||
RendererPerformancePhaseHook,
|
||||
RendererPerformancePhaseMetadata,
|
||||
RendererPerformancePhaseOutcome,
|
||||
} from './lib/renderer-performance-phase';
|
||||
@@ -0,0 +1,225 @@
|
||||
import * as sharedLogging from '../index';
|
||||
|
||||
type RendererPerformancePhase =
|
||||
| 'store.m3u-import-dispatch'
|
||||
| 'store.m3u-publish-channels'
|
||||
| 'store.xtream-delete-row'
|
||||
| 'store.xtream-import-terminal'
|
||||
| 'store.xtream-publish-categories'
|
||||
| 'store.xtream-publish-live'
|
||||
| 'store.xtream-publish-series'
|
||||
| 'store.xtream-publish-vod'
|
||||
| 'store.xtream-refresh-meta'
|
||||
| 'store.xtream-search-results';
|
||||
type RendererPerformancePhaseOutcome = 'error' | 'success';
|
||||
|
||||
interface RendererPerformancePhaseEvent {
|
||||
readonly boundary: 'end' | 'start';
|
||||
readonly epochMs: number;
|
||||
readonly metadata?: Readonly<{ items: number }>;
|
||||
readonly monotonicMs: number;
|
||||
readonly outcome?: RendererPerformancePhaseOutcome;
|
||||
readonly phase: RendererPerformancePhase;
|
||||
readonly phaseId: string;
|
||||
readonly schemaVersion: 1;
|
||||
}
|
||||
|
||||
type RendererPerformancePhaseHook = (
|
||||
event: RendererPerformancePhaseEvent
|
||||
) => void;
|
||||
|
||||
interface RendererPerformancePhaseModule {
|
||||
readonly measureRendererPerformancePhase?: <T>(
|
||||
phase: RendererPerformancePhase,
|
||||
execute: () => T,
|
||||
metadata?: () => Readonly<{ items: number }>
|
||||
) => T;
|
||||
readonly RENDERER_PERFORMANCE_PHASE?: Readonly<{
|
||||
M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch';
|
||||
M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels';
|
||||
XTREAM_DELETE_ROW: 'store.xtream-delete-row';
|
||||
XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal';
|
||||
XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories';
|
||||
XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live';
|
||||
XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series';
|
||||
XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod';
|
||||
XTREAM_REFRESH_META: 'store.xtream-refresh-meta';
|
||||
XTREAM_SEARCH_RESULTS: 'store.xtream-search-results';
|
||||
}>;
|
||||
readonly RENDERER_PERFORMANCE_PHASE_HOOK_KEY?: string;
|
||||
}
|
||||
|
||||
const rendererPerformance =
|
||||
sharedLogging as unknown as RendererPerformancePhaseModule;
|
||||
const EXPECTED_HOOK_KEY = 'iptvnator.renderer.performance-phase-hook.v1';
|
||||
const hookSymbol = Symbol.for(EXPECTED_HOOK_KEY);
|
||||
|
||||
function measure<T>(
|
||||
phase: RendererPerformancePhase,
|
||||
execute: () => T,
|
||||
metadata?: () => Readonly<{ items: number }>
|
||||
): T {
|
||||
const measurePhase = rendererPerformance.measureRendererPerformancePhase;
|
||||
expect(typeof measurePhase).toBe('function');
|
||||
if (measurePhase === undefined) {
|
||||
throw new Error('renderer performance phase helper is unavailable');
|
||||
}
|
||||
return measurePhase(phase, execute, metadata);
|
||||
}
|
||||
|
||||
function setHook(hook: RendererPerformancePhaseHook | null): void {
|
||||
const target = globalThis as unknown as Record<symbol, unknown>;
|
||||
if (hook === null) {
|
||||
delete target[hookSymbol];
|
||||
return;
|
||||
}
|
||||
target[hookSymbol] = hook;
|
||||
}
|
||||
|
||||
describe('renderer performance phases', () => {
|
||||
afterEach(() => {
|
||||
setHook(null);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not evaluate metadata while the global opt-in hook is absent', () => {
|
||||
const metadata = jest.fn(() => ({ items: 3 }));
|
||||
const resultIdentity = { imported: true };
|
||||
|
||||
const result = measure(
|
||||
'store.m3u-publish-channels',
|
||||
() => resultIdentity,
|
||||
metadata
|
||||
);
|
||||
|
||||
expect(result).toBe(resultIdentity);
|
||||
expect(metadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits an immutable successful pair with epoch and monotonic timestamps', () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
setHook((event) => events.push(event));
|
||||
const resultIdentity = { imported: true };
|
||||
|
||||
const result = measure(
|
||||
'store.m3u-publish-channels',
|
||||
() => resultIdentity,
|
||||
() => ({ items: 3 })
|
||||
);
|
||||
|
||||
expect(result).toBe(resultIdentity);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({
|
||||
boundary: 'start',
|
||||
metadata: { items: 3 },
|
||||
phase: 'store.m3u-publish-channels',
|
||||
schemaVersion: 1,
|
||||
});
|
||||
expect(events[1]).toMatchObject({
|
||||
boundary: 'end',
|
||||
metadata: { items: 3 },
|
||||
outcome: 'success',
|
||||
phase: 'store.m3u-publish-channels',
|
||||
schemaVersion: 1,
|
||||
});
|
||||
expect(events[1].phaseId).toBe(events[0].phaseId);
|
||||
expect(events[0].epochMs).toEqual(expect.any(Number));
|
||||
expect(events[0].monotonicMs).toEqual(expect.any(Number));
|
||||
expect(events[1].epochMs).toBeGreaterThanOrEqual(events[0].epochMs);
|
||||
expect(events[1].monotonicMs).toBeGreaterThanOrEqual(
|
||||
events[0].monotonicMs
|
||||
);
|
||||
expect(Object.isFrozen(events[0])).toBe(true);
|
||||
expect(Object.isFrozen(events[0].metadata)).toBe(true);
|
||||
expect(events[1].metadata).toBe(events[0].metadata);
|
||||
});
|
||||
|
||||
it('emits an error boundary and rethrows the exact product error', () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
setHook((event) => events.push(event));
|
||||
const productError = new Error('dispatch failed');
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
measure('store.m3u-import-dispatch', () => {
|
||||
throw productError;
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBe(productError);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[1]).toMatchObject({
|
||||
boundary: 'end',
|
||||
outcome: 'error',
|
||||
phase: 'store.m3u-import-dispatch',
|
||||
});
|
||||
expect(events[1].phaseId).toBe(events[0].phaseId);
|
||||
});
|
||||
|
||||
it('keeps product results and errors unchanged when metadata or hooks fail', () => {
|
||||
setHook(() => {
|
||||
throw new Error('benchmark hook failed');
|
||||
});
|
||||
const resultIdentity = { imported: true };
|
||||
|
||||
expect(
|
||||
measure(
|
||||
'store.m3u-publish-channels',
|
||||
() => resultIdentity,
|
||||
() => {
|
||||
throw new Error('metadata failed');
|
||||
}
|
||||
)
|
||||
).toBe(resultIdentity);
|
||||
|
||||
const productError = new Error('product failed');
|
||||
let thrown: unknown;
|
||||
try {
|
||||
measure('store.m3u-import-dispatch', () => {
|
||||
throw productError;
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
thrown = error;
|
||||
}
|
||||
expect(thrown).toBe(productError);
|
||||
});
|
||||
|
||||
it('exposes only the fixed phase vocabulary and item-count metadata', () => {
|
||||
expect(rendererPerformance.RENDERER_PERFORMANCE_PHASE_HOOK_KEY).toBe(
|
||||
EXPECTED_HOOK_KEY
|
||||
);
|
||||
expect(rendererPerformance.RENDERER_PERFORMANCE_PHASE).toEqual({
|
||||
M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch',
|
||||
M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels',
|
||||
XTREAM_DELETE_ROW: 'store.xtream-delete-row',
|
||||
XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal',
|
||||
XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories',
|
||||
XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live',
|
||||
XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series',
|
||||
XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod',
|
||||
XTREAM_REFRESH_META: 'store.xtream-refresh-meta',
|
||||
XTREAM_SEARCH_RESULTS: 'store.xtream-search-results',
|
||||
});
|
||||
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
setHook((event) => events.push(event));
|
||||
measure(
|
||||
'store.m3u-publish-channels',
|
||||
() => undefined,
|
||||
() =>
|
||||
({
|
||||
items: 2,
|
||||
playlistUrl: 'https://user:secret@example.test/list.m3u',
|
||||
}) as { items: number }
|
||||
);
|
||||
|
||||
expect(events.map((event) => event.metadata)).toEqual([
|
||||
{ items: 2 },
|
||||
{ items: 2 },
|
||||
]);
|
||||
expect(JSON.stringify(events)).not.toContain('secret');
|
||||
expect(JSON.stringify(events)).not.toContain('playlistUrl');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
export const RENDERER_PERFORMANCE_PHASE_HOOK_KEY =
|
||||
'iptvnator.renderer.performance-phase-hook.v1';
|
||||
|
||||
export const RENDERER_PERFORMANCE_PHASE = {
|
||||
M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch',
|
||||
M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels',
|
||||
XTREAM_DELETE_ROW: 'store.xtream-delete-row',
|
||||
XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal',
|
||||
XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories',
|
||||
XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live',
|
||||
XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series',
|
||||
XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod',
|
||||
XTREAM_REFRESH_META: 'store.xtream-refresh-meta',
|
||||
XTREAM_SEARCH_RESULTS: 'store.xtream-search-results',
|
||||
} as const;
|
||||
|
||||
export type RendererPerformancePhase =
|
||||
(typeof RENDERER_PERFORMANCE_PHASE)[keyof typeof RENDERER_PERFORMANCE_PHASE];
|
||||
|
||||
export type RendererPerformancePhaseBoundary = 'end' | 'start';
|
||||
export type RendererPerformancePhaseOutcome = 'error' | 'success';
|
||||
|
||||
export interface RendererPerformancePhaseMetadata {
|
||||
readonly items: number;
|
||||
}
|
||||
|
||||
export interface RendererPerformancePhaseEvent {
|
||||
readonly boundary: RendererPerformancePhaseBoundary;
|
||||
readonly epochMs: number;
|
||||
readonly metadata?: Readonly<RendererPerformancePhaseMetadata>;
|
||||
readonly monotonicMs: number;
|
||||
readonly outcome?: RendererPerformancePhaseOutcome;
|
||||
readonly phase: RendererPerformancePhase;
|
||||
readonly phaseId: string;
|
||||
readonly schemaVersion: 1;
|
||||
}
|
||||
|
||||
export type RendererPerformancePhaseHook = (
|
||||
event: RendererPerformancePhaseEvent
|
||||
) => void;
|
||||
|
||||
interface PhaseStart {
|
||||
readonly epochMs: number;
|
||||
readonly metadata?: Readonly<RendererPerformancePhaseMetadata>;
|
||||
readonly monotonicMs: number;
|
||||
readonly phase: RendererPerformancePhase;
|
||||
readonly phaseId: string;
|
||||
}
|
||||
|
||||
interface PhaseTimestamp {
|
||||
readonly epochMs: number;
|
||||
readonly monotonicMs: number;
|
||||
}
|
||||
|
||||
const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
|
||||
let nextPhaseId = 0;
|
||||
|
||||
export function measureRendererPerformancePhase<T>(
|
||||
phase: RendererPerformancePhase,
|
||||
execute: () => T,
|
||||
createMetadata?: () => RendererPerformancePhaseMetadata
|
||||
): T {
|
||||
const hook = readHook();
|
||||
if (hook === null) {
|
||||
return execute();
|
||||
}
|
||||
|
||||
const start = beginPhase(phase, createMetadata);
|
||||
emit(hook, {
|
||||
boundary: 'start',
|
||||
epochMs: start.epochMs,
|
||||
metadata: start.metadata,
|
||||
monotonicMs: start.monotonicMs,
|
||||
phase: start.phase,
|
||||
phaseId: start.phaseId,
|
||||
schemaVersion: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = execute();
|
||||
endPhase(hook, start, 'success');
|
||||
return result;
|
||||
} catch (error) {
|
||||
endPhase(hook, start, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function beginPhase(
|
||||
phase: RendererPerformancePhase,
|
||||
createMetadata: (() => RendererPerformancePhaseMetadata) | undefined
|
||||
): PhaseStart {
|
||||
const timestamp = sampleTimestamp();
|
||||
return {
|
||||
...timestamp,
|
||||
metadata: readMetadata(createMetadata),
|
||||
phase,
|
||||
phaseId: `renderer-performance-${++nextPhaseId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function endPhase(
|
||||
hook: RendererPerformancePhaseHook,
|
||||
start: PhaseStart,
|
||||
outcome: RendererPerformancePhaseOutcome
|
||||
): void {
|
||||
const sampled = sampleTimestamp();
|
||||
emit(hook, {
|
||||
boundary: 'end',
|
||||
epochMs: Math.max(start.epochMs, sampled.epochMs),
|
||||
metadata: start.metadata,
|
||||
monotonicMs: Math.max(start.monotonicMs, sampled.monotonicMs),
|
||||
outcome,
|
||||
phase: start.phase,
|
||||
phaseId: start.phaseId,
|
||||
schemaVersion: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function readHook(): RendererPerformancePhaseHook | null {
|
||||
try {
|
||||
const hook = (globalThis as unknown as Record<symbol, unknown>)[
|
||||
hookSymbol
|
||||
];
|
||||
return typeof hook === 'function'
|
||||
? (hook as RendererPerformancePhaseHook)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMetadata(
|
||||
createMetadata: (() => RendererPerformancePhaseMetadata) | undefined
|
||||
): Readonly<RendererPerformancePhaseMetadata> | undefined {
|
||||
if (createMetadata === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const items = createMetadata().items;
|
||||
return Number.isSafeInteger(items) && items >= 0
|
||||
? Object.freeze({ items })
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function emit(
|
||||
hook: RendererPerformancePhaseHook,
|
||||
event: RendererPerformancePhaseEvent
|
||||
): void {
|
||||
try {
|
||||
hook(Object.freeze(event));
|
||||
} catch {
|
||||
// Development-only benchmark instrumentation must stay fail-neutral.
|
||||
}
|
||||
}
|
||||
|
||||
function sampleTimestamp(): PhaseTimestamp {
|
||||
const fallbackMs = readDateNow();
|
||||
try {
|
||||
const monotonicMs = globalThis.performance?.now();
|
||||
const timeOrigin = globalThis.performance?.timeOrigin;
|
||||
if (!Number.isFinite(monotonicMs)) {
|
||||
return { epochMs: fallbackMs, monotonicMs: fallbackMs };
|
||||
}
|
||||
const epochMs =
|
||||
Number.isFinite(timeOrigin) && timeOrigin !== undefined
|
||||
? timeOrigin + monotonicMs
|
||||
: fallbackMs;
|
||||
return {
|
||||
epochMs: Number.isFinite(epochMs) ? epochMs : fallbackMs,
|
||||
monotonicMs,
|
||||
};
|
||||
} catch {
|
||||
return { epochMs: fallbackMs, monotonicMs: fallbackMs };
|
||||
}
|
||||
}
|
||||
|
||||
function readDateNow(): number {
|
||||
try {
|
||||
const value = Date.now();
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user