| 1 | import type * as Electron from "electron"; |
| 2 | import * as Cause from "effect/Cause"; |
| 3 | import * as Clock from "effect/Clock"; |
| 4 | import * as Context from "effect/Context"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Fiber from "effect/Fiber"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Path from "effect/Path"; |
| 9 | import * as Queue from "effect/Queue"; |
| 10 | |
| 11 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 12 | import { makeRotatingLogFileWriter } from "../app/DesktopObservability.ts"; |
| 13 | |
| 14 | export interface RendererIdentity { |
| 15 | readonly surface: "main" | "preview" | "splash" | "picture-in-picture" | "devtools"; |
| 16 | readonly tabId?: string; |
| 17 | } |
| 18 | |
| 19 | export class DesktopRendererHistory extends Context.Service< |
| 20 | DesktopRendererHistory, |
| 21 | { |
| 22 | readonly register: ( |
| 23 | webContents: Electron.WebContents, |
| 24 | identity: RendererIdentity, |
| 25 | ) => Effect.Effect<void>; |
| 26 | readonly recordMetrics: (metrics: ReadonlyArray<Electron.ProcessMetric>) => Effect.Effect<void>; |
| 27 | readonly shutdown: Effect.Effect<void>; |
| 28 | } |
| 29 | >()("@t3tools/desktop/telemetry/DesktopRendererHistory") {} |
| 30 | |
| 31 | interface RendererMemory { |
| 32 | readonly sampledAtUnixMs: number; |
| 33 | // Electron reports process-wide working sets in KiB, not JavaScript heaps. |
| 34 | readonly kind: "electron-process-working-set"; |
| 35 | readonly workingSetBytes: number; |
| 36 | readonly peakWorkingSetBytes: number; |
| 37 | } |
| 38 | |
| 39 | interface RendererRecord { |
| 40 | readonly version: 1; |
| 41 | readonly mainPid: number; |
| 42 | readonly mainSessionStartedAtUnixMs: number; |
| 43 | readonly timestampUnixMs: number; |
| 44 | readonly event: |
| 45 | | "created" |
| 46 | | "identified" |
| 47 | | "dom-ready" |
| 48 | | "sample" |
| 49 | | "render-process-gone" |
| 50 | | "destroyed"; |
| 51 | readonly webContentsId: number; |
| 52 | readonly surface: RendererIdentity["surface"]; |
| 53 | readonly tabId?: string; |
| 54 | readonly rendererPid: number | null; |
| 55 | readonly rendererPidSource: "current" | "last-known" | "unavailable"; |
| 56 | readonly rendererCreationTimeMs: number | null; |
| 57 | readonly memory: RendererMemory | null; |
| 58 | readonly reason?: Electron.RenderProcessGoneDetails["reason"]; |
| 59 | readonly exitCode?: number; |
| 60 | } |
| 61 | |
| 62 | interface TrackedRenderer { |
| 63 | readonly webContents: Electron.WebContents; |
| 64 | identity: RendererIdentity; |
| 65 | pid: number | null; |
| 66 | creationTimeMs: number | null; |
| 67 | memory: RendererMemory | null; |
| 68 | gone: boolean; |
| 69 | readonly removeListeners: () => void; |
| 70 | } |
| 71 | |
| 72 | const nonnegative = (value: number): number | null => |
| 73 | Number.isFinite(value) && value >= 0 ? Math.round(value) : null; |
| 74 | |
| 75 | /** Small restart-surviving PID history; never reads URLs or page content. */ |
| 76 | const make = Effect.fn("DesktopRendererHistory.make")(function* () { |
| 77 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 78 | const path = yield* Path.Path; |
| 79 | const clock = yield* Clock.Clock; |
| 80 | const context = yield* Effect.context<never>(); |
| 81 | const runFork = Effect.runForkWith(context); |
| 82 | const mainSessionStartedAtUnixMs = clock.currentTimeMillisUnsafe(); |
| 83 | const records = yield* Queue.sliding<RendererRecord>(256); |
| 84 | const active = new Map<number, TrackedRenderer>(); |
| 85 | let accepting = true; |
| 86 | const writer = yield* makeRotatingLogFileWriter({ |
| 87 | filePath: path.join(environment.logDir, "renderer-history.ndjson"), |
| 88 | maxBytes: 256 * 1024, |
| 89 | maxFiles: 2, |
| 90 | }).pipe( |
| 91 | Effect.catch(() => |
| 92 | Effect.logWarning("Renderer incident history could not be opened.").pipe( |
| 93 | Effect.as({ writeText: (_chunk: string) => Effect.void }), |
| 94 | ), |
| 95 | ), |
| 96 | ); |
| 97 | |
| 98 | const writeRecord = (record: RendererRecord) => |
| 99 | writer.writeText(`${JSON.stringify(record)}\n`).pipe(Effect.ignore); |
| 100 | const writerFiber = yield* Effect.forever( |
| 101 | Effect.uninterruptibleMask((restore) => |
| 102 | restore(Queue.take(records)).pipe(Effect.flatMap(writeRecord)), |
| 103 | ).pipe( |
| 104 | Effect.catchCauseIf( |
| 105 | (cause) => !Cause.hasInterrupts(cause), |
| 106 | () => Effect.void, |
| 107 | ), |
| 108 | ), |
| 109 | ).pipe( |
| 110 | Effect.ensuring( |
| 111 | Effect.gen(function* () { |
| 112 | while (Queue.sizeUnsafe(records) > 0) { |
| 113 | const pending = yield* Queue.takeAll(records); |
| 114 | yield* Effect.forEach(pending, writeRecord, { discard: true }); |
| 115 | } |
| 116 | }), |
| 117 | ), |
| 118 | Effect.forkScoped, |
| 119 | ); |
| 120 | |
| 121 | const readPid = (renderer: TrackedRenderer): RendererRecord["rendererPidSource"] => { |
| 122 | try { |
| 123 | if (!renderer.gone && !renderer.webContents.isDestroyed()) { |
| 124 | const pid = renderer.webContents.getOSProcessId(); |
| 125 | if (Number.isInteger(pid) && pid > 0) { |
| 126 | if (pid !== renderer.pid) { |
| 127 | renderer.pid = pid; |
| 128 | renderer.creationTimeMs = null; |
| 129 | renderer.memory = null; |
| 130 | } |
| 131 | return "current"; |
| 132 | } |
| 133 | } |
| 134 | } catch { |
| 135 | // Electron can remove a guest before its destruction callback runs. |
| 136 | } |
| 137 | return renderer.pid === null ? "unavailable" : "last-known"; |
| 138 | }; |
| 139 | const record = ( |
| 140 | renderer: TrackedRenderer, |
| 141 | event: RendererRecord["event"], |
| 142 | details?: Electron.RenderProcessGoneDetails, |
| 143 | ): void => { |
| 144 | const rendererPidSource = readPid(renderer); |
| 145 | Queue.offerUnsafe(records, { |
| 146 | version: 1, |
| 147 | mainPid: process.pid, |
| 148 | mainSessionStartedAtUnixMs, |
| 149 | timestampUnixMs: clock.currentTimeMillisUnsafe(), |
| 150 | event, |
| 151 | webContentsId: renderer.webContents.id, |
| 152 | ...renderer.identity, |
| 153 | rendererPid: renderer.pid, |
| 154 | rendererPidSource, |
| 155 | rendererCreationTimeMs: renderer.creationTimeMs, |
| 156 | memory: renderer.memory, |
| 157 | ...(details === undefined |
| 158 | ? {} |
| 159 | : { |
| 160 | reason: details.reason, |
| 161 | ...(Number.isFinite(details.exitCode) |
| 162 | ? { exitCode: Math.round(details.exitCode) } |
| 163 | : {}), |
| 164 | }), |
| 165 | }); |
| 166 | }; |
| 167 | |
| 168 | const register = (webContents: Electron.WebContents, identity: RendererIdentity) => |
| 169 | Effect.sync(() => { |
| 170 | if (!accepting || webContents.isDestroyed()) return; |
| 171 | const boundedIdentity: RendererIdentity = { |
| 172 | surface: identity.surface, |
| 173 | ...(identity.tabId === undefined ? {} : { tabId: identity.tabId.slice(0, 128) }), |
| 174 | }; |
| 175 | const existing = active.get(webContents.id); |
| 176 | if (existing !== undefined) { |
| 177 | if ( |
| 178 | existing.identity.surface !== boundedIdentity.surface || |
| 179 | existing.identity.tabId !== boundedIdentity.tabId |
| 180 | ) { |
| 181 | existing.identity = boundedIdentity; |
| 182 | record(existing, "identified"); |
| 183 | } |
| 184 | return; |
| 185 | } |
| 186 | const onDomReady = () => { |
| 187 | if (renderer.gone) { |
| 188 | renderer.pid = null; |
| 189 | renderer.creationTimeMs = null; |
| 190 | renderer.memory = null; |
| 191 | renderer.gone = false; |
| 192 | } |
| 193 | record(renderer, "dom-ready"); |
| 194 | }; |
| 195 | const onDevToolsOpened = () => { |
| 196 | const tools = webContents.devToolsWebContents; |
| 197 | if (tools) |
| 198 | runFork( |
| 199 | register(tools, { |
| 200 | surface: "devtools", |
| 201 | ...(renderer.identity.tabId === undefined ? {} : { tabId: renderer.identity.tabId }), |
| 202 | }), |
| 203 | ); |
| 204 | }; |
| 205 | const onGone = (_event: Electron.Event, details: Electron.RenderProcessGoneDetails) => { |
| 206 | readPid(renderer); |
| 207 | renderer.gone = true; |
| 208 | record(renderer, "render-process-gone", details); |
| 209 | }; |
| 210 | const onDestroyed = () => { |
| 211 | record(renderer, "destroyed"); |
| 212 | active.delete(webContents.id); |
| 213 | renderer.removeListeners(); |
| 214 | }; |
| 215 | const renderer: TrackedRenderer = { |
| 216 | webContents, |
| 217 | identity: boundedIdentity, |
| 218 | pid: null, |
| 219 | creationTimeMs: null, |
| 220 | memory: null, |
| 221 | gone: false, |
| 222 | removeListeners: () => { |
| 223 | webContents.removeListener("dom-ready", onDomReady); |
| 224 | webContents.removeListener("devtools-opened", onDevToolsOpened); |
| 225 | webContents.removeListener("render-process-gone", onGone); |
| 226 | webContents.removeListener("destroyed", onDestroyed); |
| 227 | }, |
| 228 | }; |
| 229 | active.set(webContents.id, renderer); |
| 230 | webContents.on("dom-ready", onDomReady); |
| 231 | webContents.on("devtools-opened", onDevToolsOpened); |
| 232 | webContents.on("render-process-gone", onGone); |
| 233 | webContents.once("destroyed", onDestroyed); |
| 234 | record(renderer, "created"); |
| 235 | }).pipe( |
| 236 | Effect.catchCauseIf( |
| 237 | (cause) => !Cause.hasInterrupts(cause), |
| 238 | () => Effect.void, |
| 239 | ), |
| 240 | ); |
| 241 | |
| 242 | let lastSampledAtUnixMs = -Infinity; |
| 243 | const recordMetrics = (metrics: ReadonlyArray<Electron.ProcessMetric>) => |
| 244 | Effect.sync(() => { |
| 245 | if (!accepting) return; |
| 246 | const sampledAtUnixMs = clock.currentTimeMillisUnsafe(); |
| 247 | if (sampledAtUnixMs - lastSampledAtUnixMs < 30_000) return; |
| 248 | lastSampledAtUnixMs = sampledAtUnixMs; |
| 249 | const byPid = new Map(metrics.map((metric) => [metric.pid, metric])); |
| 250 | for (const renderer of active.values()) { |
| 251 | const pidSource = readPid(renderer); |
| 252 | // A last-known PID may already have been reused by another process. |
| 253 | const metric = |
| 254 | pidSource !== "current" || renderer.pid === null ? undefined : byPid.get(renderer.pid); |
| 255 | if (metric !== undefined) { |
| 256 | renderer.creationTimeMs = nonnegative(metric.creationTime); |
| 257 | renderer.memory = { |
| 258 | sampledAtUnixMs, |
| 259 | kind: "electron-process-working-set", |
| 260 | workingSetBytes: nonnegative(metric.memory.workingSetSize * 1024) ?? 0, |
| 261 | peakWorkingSetBytes: nonnegative(metric.memory.peakWorkingSetSize * 1024) ?? 0, |
| 262 | }; |
| 263 | } |
| 264 | // A process can host multiple contents; these rows must not be summed. |
| 265 | record(renderer, "sample"); |
| 266 | } |
| 267 | }).pipe( |
| 268 | Effect.catchCauseIf( |
| 269 | (cause) => !Cause.hasInterrupts(cause), |
| 270 | () => Effect.void, |
| 271 | ), |
| 272 | ); |
| 273 | const shutdown = Effect.sync(() => { |
| 274 | accepting = false; |
| 275 | for (const renderer of active.values()) renderer.removeListeners(); |
| 276 | active.clear(); |
| 277 | }).pipe(Effect.andThen(Fiber.interrupt(writerFiber)), Effect.asVoid); |
| 278 | yield* Effect.addFinalizer(() => shutdown); |
| 279 | |
| 280 | return DesktopRendererHistory.of({ register, recordMetrics, shutdown }); |
| 281 | }); |
| 282 | |
| 283 | export const layer = Layer.effect(DesktopRendererHistory, make()); |