| 1 | export interface CpuProfile { |
| 2 | nodes: { id: number; callFrame: { functionName: string; url: string; lineNumber: number; columnNumber?: number } }[]; |
| 3 | samples?: number[]; |
| 4 | timeDeltas?: number[]; |
| 5 | startTime: number; |
| 6 | endTime: number; |
| 7 | } |
| 8 | export interface ProfileFrame { label: string; samples: number; selfMs: number } |
| 9 | export interface RendererProfileResult { |
| 10 | status: "captured" | "inactive" | "busy" | "cooldown" | "limit" | "unavailable" | "cancelled" | "failed"; |
| 11 | durationMs?: number; |
| 12 | frames?: ProfileFrame[]; |
| 13 | } |
| 14 | export interface ProfileTarget { |
| 15 | debugger: { |
| 16 | isAttached(): boolean; |
| 17 | attach(): void; |
| 18 | detach(): void; |
| 19 | sendCommand(method: string, params?: Record<string, unknown>): Promise<any>; |
| 20 | on(event: "detach", listener: () => void): unknown; |
| 21 | removeListener(event: "detach", listener: () => void): unknown; |
| 22 | }; |
| 23 | isDestroyed(): boolean; |
| 24 | isDevToolsOpened(): boolean; |
| 25 | } |
| 26 | interface Dependencies { |
| 27 | target(): ProfileTarget | null; |
| 28 | isForeground(): boolean; |
| 29 | onInvalidated(listener: () => void): () => void; |
| 30 | analyse(profile: CpuProfile): Promise<ProfileFrame[]>; |
| 31 | now?: () => number; |
| 32 | durationMs?: number; |
| 33 | cooldownMs?: number; |
| 34 | maxCaptures?: number; |
| 35 | commandTimeoutMs?: number; |
| 36 | } |
| 37 | |
| 38 | // Own only the trusted renderer's debugger connection. Never share or steal a |
| 39 | // connection from DevTools or another caller; cancellation retains ownership |
| 40 | // until stop/detach has completed, so a late reply cannot affect a new capture. |
| 41 | export class RendererDiagnostics { |
| 42 | private active: { requestId?: string; cancel(): void } | undefined; |
| 43 | private lastAttempt = -Infinity; |
| 44 | private captures = 0; |
| 45 | private disposed = false; |
| 46 | constructor(private readonly deps: Dependencies) {} |
| 47 | get busy(): boolean { return this.active !== undefined; } |
| 48 | |
| 49 | cancel(requestId?: string): void { |
| 50 | if (requestId !== undefined && this.active?.requestId !== requestId) return; |
| 51 | this.active?.cancel(); |
| 52 | } |
| 53 | dispose(): void { this.disposed = true; this.cancel(); } |
| 54 | |
| 55 | async capture(requestId?: string): Promise<RendererProfileResult> { |
| 56 | if (this.disposed) return { status: "unavailable" }; |
| 57 | if (this.active) return { status: "busy" }; |
| 58 | if (!this.deps.isForeground()) return { status: "inactive" }; |
| 59 | const now = (this.deps.now ?? (() => performance.now()))(); |
| 60 | if (this.captures >= (this.deps.maxCaptures ?? 3)) return { status: "limit" }; |
| 61 | if (now - this.lastAttempt < (this.deps.cooldownMs ?? 600_000)) return { status: "cooldown" }; |
| 62 | const target = this.deps.target(); |
| 63 | if (!target || target.isDestroyed() || target.isDevToolsOpened() || target.debugger.isAttached()) return { status: "unavailable" }; |
| 64 | this.lastAttempt = now; |
| 65 | this.captures++; |
| 66 | let cancelled = false; |
| 67 | let ownsConnection = false; |
| 68 | let wake!: () => void; |
| 69 | const interrupted = new Promise<void>((resolve) => { wake = resolve; }); |
| 70 | const owner = { requestId, cancel: () => { cancelled = true; wake(); } }; |
| 71 | this.active = owner; |
| 72 | const onDetach = () => { ownsConnection = false; owner.cancel(); }; |
| 73 | let removeInvalidated = () => {}; |
| 74 | const inspector = target.debugger; |
| 75 | const command = <T>(method: string, params?: Record<string, unknown>) => deadline<T>( |
| 76 | inspector.sendCommand(method, params), this.deps.commandTimeoutMs ?? 1500, |
| 77 | ); |
| 78 | let timer: ReturnType<typeof setTimeout> | undefined; |
| 79 | const startedAt = (this.deps.now ?? (() => performance.now()))(); |
| 80 | try { |
| 81 | removeInvalidated = this.deps.onInvalidated(owner.cancel); |
| 82 | if (cancelled) return { status: "cancelled" }; |
| 83 | inspector.attach(); |
| 84 | ownsConnection = true; |
| 85 | inspector.on("detach", onDetach); |
| 86 | await command("Profiler.enable"); |
| 87 | if (cancelled) return { status: "cancelled" }; |
| 88 | await command("Profiler.setSamplingInterval", { interval: 10_000 }); |
| 89 | if (cancelled) return { status: "cancelled" }; |
| 90 | await command("Profiler.start"); |
| 91 | await Promise.race([ |
| 92 | interrupted, |
| 93 | new Promise<void>((resolve) => { timer = setTimeout(resolve, this.deps.durationMs ?? 5000); }), |
| 94 | ]); |
| 95 | if (!ownsConnection || target.isDestroyed()) return { status: "cancelled" }; |
| 96 | const result = await command<{ profile: CpuProfile }>("Profiler.stop"); |
| 97 | if (cancelled) return { status: "cancelled" }; |
| 98 | // The raw profile never enters the renderer or its report payload. |
| 99 | const frames = await this.deps.analyse(result.profile); |
| 100 | if (cancelled) return { status: "cancelled" }; |
| 101 | return { status: "captured", durationMs: (this.deps.now ?? (() => performance.now()))() - startedAt, frames }; |
| 102 | } catch { |
| 103 | return { status: cancelled ? "cancelled" : "failed" }; |
| 104 | } finally { |
| 105 | clearTimeout(timer); |
| 106 | try { removeInvalidated(); } catch { /* Cleanup cannot strand the lease. */ } |
| 107 | if (ownsConnection) { |
| 108 | try { inspector.detach(); } catch { /* Renderer exit also ends the session. */ } |
| 109 | } |
| 110 | try { inspector.removeListener("detach", onDetach); } catch { /* A destroyed target may reject cleanup. */ } |
| 111 | if (this.active === owner) this.active = undefined; |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | function deadline<T>(work: Promise<T>, ms: number): Promise<T> { |
| 117 | return new Promise((resolve, reject) => { |
| 118 | const timer = setTimeout(() => reject(new Error("diagnostic command timeout")), ms); |
| 119 | work.then(resolve, reject).finally(() => clearTimeout(timer)); |
| 120 | }); |
| 121 | } |
| 122 |