1import type * as Electron from "electron";
2import * as Cause from "effect/Cause";
3import * as Clock from "effect/Clock";
4import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import * as Queue from "effect/Queue";
10
11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
12import { makeRotatingLogFileWriter } from "../app/DesktopObservability.ts";
13
14export interface RendererIdentity {
15 readonly surface: "main" | "preview" | "splash" | "picture-in-picture" | "devtools";
16 readonly tabId?: string;
17}
18
19export 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
31interface 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
39interface 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
62interface 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
72const 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. */
76const 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
283export const layer = Layer.effect(DesktopRendererHistory, make());