1import { DesktopHostTelemetryMessage } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import * as Deferred from "effect/Deferred";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";
8import * as Fiber from "effect/Fiber";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Ref from "effect/Ref";
13import * as Schema from "effect/Schema";
14import * as Scope from "effect/Scope";
15import * as Stream from "effect/Stream";
16import * as TestClock from "effect/testing/TestClock";
17
18import type * as Electron from "electron";
19import * as NodeEvents from "node:events";
20
21import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
22import * as ElectronApp from "../electron/ElectronApp.ts";
23import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts";
24import * as DesktopTelemetryPublisher from "./DesktopTelemetryPublisher.ts";
25import * as DesktopRendererHistory from "./DesktopRendererHistory.ts";
26
27const layerHistory = Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, {
28 register: () => Effect.void,
29 recordMetrics: () => Effect.void,
30 shutdown: Effect.void,
31});
32
33function layerElectronApp(
34 metrics: ReadonlyArray<Electron.ProcessMetric>,
35 onMetricsRead: () => void = () => undefined,
36) {
37 return Layer.succeed(ElectronApp.ElectronApp, {
38 metadata: Effect.die("unexpected metadata read"),
39 name: Effect.succeed("T3 Code"),
40 systemLocale: Effect.succeed("en-US"),
41 whenReady: Effect.void,
42 quit: Effect.void,
43 exit: () => Effect.void,
44 relaunch: () => Effect.void,
45 setPath: () => Effect.void,
46 setName: () => Effect.void,
47 setAboutPanelOptions: () => Effect.void,
48 setAppUserModelId: () => Effect.void,
49 getAppMetrics: Effect.sync(() => {
50 onMetricsRead();
51 return metrics;
52 }),
53 setAsDefaultProtocolClient: () => Effect.succeed(true),
54 setDesktopName: () => Effect.void,
55 setDockIcon: () => Effect.void,
56 appendCommandLineSwitch: () => Effect.void,
57 removeCommandLineSwitch: () => Effect.void,
58 onBeforeQuitForUpdate: () => Effect.void,
59 on: () => Effect.void,
60 } satisfies ElectronApp.ElectronApp["Service"]);
61}
62
63describe("DesktopTelemetryPublisher", () => {
64 it.effect("stops when its scope closes during an Electron telemetry sample", () =>
65 Effect.gen(function* () {
66 const pollStarted = yield* Deferred.make<void>();
67 const blockPoll = yield* Deferred.make<void>();
68 const layerPower = Layer.succeed(
69 ElectronPowerMonitor.ElectronPowerMonitor,
70 ElectronPowerMonitor.ElectronPowerMonitor.of({
71 isOnBatteryPower: Effect.succeed(false),
72 getSystemIdleTime: Deferred.succeed(pollStarted, undefined).pipe(
73 Effect.andThen(Deferred.await(blockPoll)),
74 Effect.as(0),
75 ),
76 getSystemIdleState: () => Effect.succeed("active"),
77 getCurrentThermalState: Effect.succeed("nominal"),
78 onSimpleEvent: () => Effect.void,
79 onThermalStateChange: () => Effect.void,
80 onSpeedLimitChange: () => Effect.void,
81 }),
82 );
83 const layer = DesktopTelemetryPublisher.layer.pipe(
84 Layer.provide(Layer.mergeAll(layerElectronApp([]), layerPower, layerHistory)),
85 );
86 const scope = yield* Scope.make();
87
88 yield* Layer.buildWithScope(layer, scope);
89 yield* Deferred.await(pollStarted);
90
91 const closeFiber = yield* Scope.close(scope, Exit.void).pipe(Effect.forkDetach);
92 yield* Effect.yieldNow;
93 yield* Effect.yieldNow;
94
95 assert.isDefined(closeFiber.pollUnsafe());
96 }),
97 );
98
99 it.effect("publishes Electron metrics and event-driven power state over NDJSON", () =>
100 Effect.gen(function* () {
101 const onBattery = yield* Ref.make(false);
102 const systemIdleState = yield* Ref.make<ElectronPowerMonitor.ElectronIdleState>("active");
103 let beforeSystemIdleState: Effect.Effect<void> = Effect.void;
104 let metricsReadCount = 0;
105 const recordedMetrics: ReadonlyArray<Electron.ProcessMetric>[] = [];
106 const simpleListeners = new Map<string, () => void>();
107 let thermalListener: ((state: ElectronPowerMonitor.ElectronThermalState) => void) | null =
108 null;
109 let speedLimitListener: ((limit: number) => void) | null = null;
110 const metrics = [
111 {
112 pid: 4_242,
113 type: "Browser",
114 creationTime: 1_000.75,
115 name: "electron",
116 cpu: {
117 percentCPUUsage: 12.5,
118 cumulativeCPUUsage: 3.25,
119 idleWakeupsPerSecond: 7,
120 },
121 memory: {
122 workingSetSize: 2_048,
123 peakWorkingSetSize: 4_096,
124 },
125 } as Electron.ProcessMetric,
126 ];
127 const layerPower = Layer.succeed(
128 ElectronPowerMonitor.ElectronPowerMonitor,
129 ElectronPowerMonitor.ElectronPowerMonitor.of({
130 isOnBatteryPower: Ref.get(onBattery),
131 getSystemIdleTime: Effect.succeed(5),
132 getSystemIdleState: () =>
133 beforeSystemIdleState.pipe(Effect.andThen(Ref.get(systemIdleState))),
134 getCurrentThermalState: Effect.succeed("nominal"),
135 onSimpleEvent: (eventName, listener) =>
136 Effect.sync(() => {
137 simpleListeners.set(eventName, listener);
138 }),
139 onThermalStateChange: (listener) =>
140 Effect.sync(() => {
141 thermalListener = listener;
142 }),
143 onSpeedLimitChange: (listener) =>
144 Effect.sync(() => {
145 speedLimitListener = listener;
146 }),
147 }),
148 );
149 const layer = DesktopTelemetryPublisher.layer.pipe(
150 Layer.provide(
151 Layer.mergeAll(
152 layerElectronApp(metrics, () => {
153 metricsReadCount += 1;
154 }),
155 layerPower,
156 Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, {
157 register: () => Effect.void,
158 shutdown: Effect.void,
159 recordMetrics: (sample) =>
160 Effect.sync(() => {
161 recordedMetrics.push(sample);
162 }),
163 }),
164 ),
165 ),
166 );
167
168 yield* Effect.gen(function* () {
169 const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
170 const encoded = yield* publisher.encoded.pipe(Stream.take(2), Stream.runCollect);
171 const decoder = new TextDecoder();
172 const decodeMessage = Schema.decodeUnknownEffect(
173 Schema.fromJsonString(DesktopHostTelemetryMessage),
174 );
175 const messages = yield* Effect.forEach(encoded, (bytes) =>
176 decodeMessage(decoder.decode(bytes).trim()),
177 );
178
179 const hello = messages[0];
180 if (hello?.type !== "desktopTelemetryHello") {
181 return assert.fail("Expected the first telemetry message to be the hello.");
182 }
183 assert.equal(hello.electronPid, process.pid);
184 const initialSnapshot = messages[1];
185 if (initialSnapshot?.type !== "desktopTelemetry") {
186 return assert.fail("Expected the second telemetry message to be a snapshot.");
187 }
188 assert.deepEqual(initialSnapshot.electronProcesses, []);
189 assert.equal(initialSnapshot.electronPid, process.pid);
190 assert.equal(metricsReadCount, 1);
191 assert.deepEqual(recordedMetrics, [metrics]);
192
193 const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild);
194 yield* Effect.yieldNow;
195 yield* publisher.handleControlForSource("primary-backend", {
196 version: 1,
197 type: "setDiagnosticsDemand",
198 enabled: true,
199 });
200 const demandedSnapshot = Option.getOrThrow(yield* Fiber.join(nextSnapshotFiber));
201 assert.equal(demandedSnapshot.electronProcesses[0]?.pid, 4_242);
202 assert.equal(demandedSnapshot.electronProcesses[0]?.creationTimeMs, 1_001);
203 assert.equal(demandedSnapshot.electronProcesses[0]?.cpuPercent, 12.5);
204 assert.equal(demandedSnapshot.electronProcesses[0]?.workingSetBytes, 2_048 * 1_024);
205 assert.equal(metricsReadCount, 2);
206 assert.deepEqual(recordedMetrics, [metrics, metrics]);
207 yield* publisher.handleControlForSource("secondary-backend", {
208 version: 1,
209 type: "setDiagnosticsDemand",
210 enabled: true,
211 });
212 yield* publisher.handleControlForSource("primary-backend", {
213 version: 1,
214 type: "setDiagnosticsDemand",
215 enabled: false,
216 });
217 yield* publisher.handleControlForSource("old-backend", {
218 version: 1,
219 type: "setDiagnosticsDemand",
220 enabled: true,
221 });
222 yield* publisher.handleControlForSource("secondary-backend", {
223 version: 1,
224 type: "setDiagnosticsDemand",
225 enabled: false,
226 });
227 yield* Effect.all(
228 [
229 publisher.removeControlSource("old-backend"),
230 publisher.handleControlForSource("replacement-backend", {
231 version: 1,
232 type: "setDiagnosticsDemand",
233 enabled: true,
234 }),
235 ],
236 { concurrency: "unbounded", discard: true },
237 );
238
239 const batterySnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
240 Effect.forkChild,
241 );
242 yield* Effect.yieldNow;
243 simpleListeners.get("on-battery")?.();
244 const batterySnapshot = Option.getOrThrow(yield* Fiber.join(batterySnapshotFiber));
245 assert.equal(batterySnapshot.power.onBattery, "true");
246 yield* Ref.set(onBattery, true);
247
248 const metricsAfterBatteryEvent = metricsReadCount;
249 yield* TestClock.adjust(Duration.millis(4_999));
250 assert.equal(metricsReadCount, metricsAfterBatteryEvent);
251 yield* TestClock.adjust(Duration.millis(1));
252 assert.equal(metricsReadCount, metricsAfterBatteryEvent + 1);
253 assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.onBattery, "true");
254
255 yield* Ref.set(onBattery, false);
256 const metricsBeforePolledAc = metricsReadCount;
257 yield* TestClock.adjust(Duration.seconds(5));
258 assert.equal(metricsReadCount, metricsBeforePolledAc + 1);
259 assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.onBattery, "false");
260 yield* TestClock.adjust(Duration.seconds(1));
261 assert.equal(metricsReadCount, metricsBeforePolledAc + 2);
262
263 const suspendedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
264 Effect.forkChild,
265 );
266 yield* Effect.yieldNow;
267 simpleListeners.get("suspend")?.();
268 const suspendedSnapshot = Option.getOrThrow(yield* Fiber.join(suspendedSnapshotFiber));
269 assert.isTrue(suspendedSnapshot.power.suspended);
270
271 const constrainedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
272 Effect.forkChild,
273 );
274 yield* Effect.yieldNow;
275 thermalListener?.("serious");
276 const constrainedSnapshot = Option.getOrThrow(yield* Fiber.join(constrainedSnapshotFiber));
277 assert.equal(constrainedSnapshot.power.thermalState, "serious");
278 assert.isTrue(constrainedSnapshot.power.suspended);
279
280 const metricsAfterThermalEvent = metricsReadCount;
281 const recoveredSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
282 Effect.forkChild,
283 );
284 yield* TestClock.adjust(Duration.millis(14_999));
285 assert.equal(metricsReadCount, metricsAfterThermalEvent);
286 yield* TestClock.adjust(Duration.millis(1));
287 assert.equal(metricsReadCount, metricsAfterThermalEvent + 1);
288 const recoveredSnapshot = Option.getOrThrow(yield* Fiber.join(recoveredSnapshotFiber));
289 assert.isFalse(recoveredSnapshot.power.suspended);
290
291 const speedLimitSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
292 Effect.forkChild,
293 );
294 yield* Effect.yieldNow;
295 speedLimitListener?.(65);
296 const speedLimitSnapshot = Option.getOrThrow(yield* Fiber.join(speedLimitSnapshotFiber));
297 assert.equal(Option.getOrNull(speedLimitSnapshot.speedLimitPercent), 65);
298
299 const encodedSpeedLimit = yield* publisher.encoded.pipe(Stream.take(2), Stream.runCollect);
300 const decodedSpeedLimit = yield* decodeMessage(decoder.decode(encodedSpeedLimit[1]).trim());
301 if (decodedSpeedLimit.type !== "desktopTelemetry") {
302 return assert.fail("Expected the encoded telemetry message to be a snapshot.");
303 }
304 assert.equal(Option.getOrNull(decodedSpeedLimit.speedLimitPercent), 65);
305 assert.equal(decodedSpeedLimit.electronProcesses[0]?.pid, 4_242);
306 assert.equal(decodedSpeedLimit.electronProcesses[0]?.creationTimeMs, 1_001);
307
308 const metricsBeforeSecondaryOnlySample = metricsReadCount;
309 yield* TestClock.adjust(Duration.seconds(15));
310 assert.equal(metricsReadCount, metricsBeforeSecondaryOnlySample + 1);
311 assert.equal(
312 (yield* publisher.latest).pipe(Option.getOrThrow).electronProcesses[0]?.pid,
313 4_242,
314 );
315
316 const stoppedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
317 Effect.forkChild,
318 );
319 yield* Effect.yieldNow;
320 yield* publisher.handleControlForSource("replacement-backend", {
321 version: 1,
322 type: "setDiagnosticsDemand",
323 enabled: false,
324 });
325 const stoppedSnapshot = Option.getOrThrow(yield* Fiber.join(stoppedSnapshotFiber));
326 assert.deepEqual(stoppedSnapshot.electronProcesses, []);
327 const metricsAfterStopping = metricsReadCount;
328 const configuredSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
329 Effect.forkChild,
330 );
331 yield* Effect.yieldNow;
332 yield* publisher.handleControlForSource("primary-backend", {
333 version: 1,
334 type: "setHostPowerIntervals",
335 activeIntervalMs: 7_000,
336 idleIntervalMs: 11_000,
337 });
338 const configuredSequence = Option.getOrThrow(
339 yield* Fiber.join(configuredSnapshotFiber),
340 ).sequence;
341
342 yield* TestClock.adjust(Duration.millis(6_999));
343 assert.equal(
344 (yield* publisher.latest).pipe(Option.getOrThrow).sequence,
345 configuredSequence,
346 );
347 assert.equal(metricsReadCount, metricsAfterStopping + 1);
348 yield* TestClock.adjust(Duration.millis(1));
349 assert.equal(
350 (yield* publisher.latest).pipe(Option.getOrThrow).sequence,
351 configuredSequence + 1,
352 );
353 assert.equal(metricsReadCount, metricsAfterStopping + 2);
354
355 yield* Ref.set(systemIdleState, "locked");
356 yield* TestClock.adjust(Duration.seconds(7));
357 const lockedSequence = (yield* publisher.latest).pipe(Option.getOrThrow).sequence;
358 assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.locked, "true");
359 yield* TestClock.adjust(Duration.millis(10_999));
360 assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).sequence, lockedSequence);
361 yield* TestClock.adjust(Duration.millis(1));
362 assert.equal(
363 (yield* publisher.latest).pipe(Option.getOrThrow).sequence,
364 lockedSequence + 1,
365 );
366
367 yield* Ref.set(systemIdleState, "active");
368 yield* TestClock.adjust(Duration.seconds(11));
369 const unlockedSnapshot = (yield* publisher.latest).pipe(Option.getOrThrow);
370 assert.equal(unlockedSnapshot.power.locked, "false");
371 assert.equal(unlockedSnapshot.power.idle, "false");
372
373 yield* TestClock.adjust(Duration.millis(6_999));
374 assert.equal(
375 (yield* publisher.latest).pipe(Option.getOrThrow).sequence,
376 unlockedSnapshot.sequence,
377 );
378 yield* TestClock.adjust(Duration.millis(1));
379 assert.equal(
380 (yield* publisher.latest).pipe(Option.getOrThrow).sequence,
381 unlockedSnapshot.sequence + 1,
382 );
383
384 const pollStarted = yield* Deferred.make<void>();
385 const releasePoll = yield* Deferred.make<void>();
386 beforeSystemIdleState = Deferred.succeed(pollStarted, undefined).pipe(
387 Effect.andThen(Deferred.await(releasePoll)),
388 );
389 const concurrentEventSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
390 Effect.forkChild,
391 );
392 yield* Effect.yieldNow;
393 yield* publisher.handleControlForSource("primary-backend", {
394 version: 1,
395 type: "setDiagnosticsDemand",
396 enabled: true,
397 });
398 yield* Deferred.await(pollStarted);
399 simpleListeners.get("lock-screen")?.();
400 thermalListener?.("critical");
401 yield* Effect.yieldNow;
402 yield* Effect.yieldNow;
403 yield* Deferred.succeed(releasePoll, undefined);
404
405 const concurrentEventSnapshot = Option.getOrThrow(
406 yield* Fiber.join(concurrentEventSnapshotFiber),
407 );
408 assert.equal(concurrentEventSnapshot.power.locked, "true");
409 assert.equal(concurrentEventSnapshot.power.thermalState, "critical");
410 }).pipe(Effect.provide(layer));
411 }),
412 );
413
414 it.effect("routes requestDesktopUpdate control messages and replays update reports", () =>
415 Effect.gen(function* () {
416 const layerPower = Layer.succeed(
417 ElectronPowerMonitor.ElectronPowerMonitor,
418 ElectronPowerMonitor.ElectronPowerMonitor.of({
419 isOnBatteryPower: Effect.succeed(false),
420 getSystemIdleTime: Effect.succeed(0),
421 getSystemIdleState: () => Effect.succeed("active"),
422 getCurrentThermalState: Effect.succeed("nominal"),
423 onSimpleEvent: () => Effect.void,
424 onThermalStateChange: () => Effect.void,
425 onSpeedLimitChange: () => Effect.void,
426 }),
427 );
428 const layer = DesktopTelemetryPublisher.layer.pipe(
429 Layer.provide(Layer.mergeAll(layerElectronApp([]), layerPower, layerHistory)),
430 );
431
432 yield* Effect.gen(function* () {
433 const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
434
435 const requestFiber = yield* Stream.runHead(publisher.updateRequests).pipe(Effect.forkChild);
436 yield* Effect.yieldNow;
437 yield* publisher.handleControlForSource("test", {
438 version: 1,
439 type: "requestDesktopUpdate",
440 requestId: "req-9",
441 });
442 const received = yield* Fiber.join(requestFiber);
443 assert.equal(Option.getOrThrow(received).requestId, "req-9");
444
445 const report = {
446 version: 1,
447 type: "desktopUpdateStatus",
448 outcome: "up-to-date",
449 state: {
450 enabled: true,
451 status: "up-to-date",
452 channel: "latest",
453 currentVersion: "1.2.3",
454 hostArch: "arm64",
455 appArch: "arm64",
456 runningUnderArm64Translation: false,
457 availableVersion: null,
458 downloadedVersion: null,
459 releaseNotes: [],
460 downloadPercent: null,
461 checkedAt: null,
462 message: null,
463 errorContext: null,
464 canRetry: false,
465 omittedReleaseCount: 0,
466 },
467 } as const;
468 yield* publisher.publishUpdateReport(report);
469
470 // A subscriber that attaches after the publish (the backend spawned
471 // by a relaunch) still sees the latest report replayed.
472 const decoder = new TextDecoder();
473 const decodeMessage = Schema.decodeUnknownEffect(
474 Schema.fromJsonString(DesktopHostTelemetryMessage),
475 );
476 const replayed = yield* publisher.encoded.pipe(
477 Stream.mapEffect((bytes) => decodeMessage(decoder.decode(bytes).trim())),
478 Stream.filter((message) => message.type === "desktopUpdateStatus"),
479 Stream.runHead,
480 );
481 const replayedReport = Option.getOrThrow(replayed);
482 if (replayedReport.type !== "desktopUpdateStatus") {
483 return assert.fail("Expected a desktop update status report.");
484 }
485 assert.equal(replayedReport.outcome, "up-to-date");
486 assert.equal(replayedReport.state.currentVersion, "1.2.3");
487 }).pipe(Effect.provide(layer));
488 }),
489 );
490});
491
492describe("DesktopRendererHistory", () => {
493 it.effect(
494 "persists surface identity and memory through renderer replacement and destruction",
495 () =>
496 Effect.gen(function* () {
497 const fileSystem = yield* FileSystem.FileSystem;
498 const directory = yield* fileSystem.makeTempDirectoryScoped({
499 prefix: "t3-renderer-history-",
500 });
501 let previewPid = 7_002;
502 let previewDestroyed = false;
503 let urlReads = 0;
504 const main = Object.assign(new NodeEvents.EventEmitter(), {
505 id: 1,
506 getOSProcessId: () => 7_001,
507 isDestroyed: () => false,
508 getURL: () => {
509 urlReads += 1;
510 return "https://private.invalid/main?secret=private";
511 },
512 });
513 const preview = Object.assign(new NodeEvents.EventEmitter(), {
514 id: 2,
515 getOSProcessId: () => previewPid,
516 isDestroyed: () => previewDestroyed,
517 getURL: () => {
518 urlReads += 1;
519 return "https://private.invalid/preview?secret=private";
520 },
521 });
522 const metrics = [7_001, 7_002].map(
523 (pid) =>
524 ({
525 pid,
526 type: "Tab",
527 creationTime: pid * 100,
528 memory: { workingSetSize: 2_048, peakWorkingSetSize: 4_096 },
529 cpu: { percentCPUUsage: 0, cumulativeCPUUsage: 0, idleWakeupsPerSecond: 0 },
530 }) satisfies Electron.ProcessMetric,
531 );
532 const recordSchema = Schema.fromJsonString(
533 Schema.Struct({
534 event: Schema.String,
535 mainPid: Schema.Number,
536 mainSessionStartedAtUnixMs: Schema.Number,
537 timestampUnixMs: Schema.Number,
538 webContentsId: Schema.Number,
539 surface: Schema.String,
540 tabId: Schema.optional(Schema.String),
541 rendererPid: Schema.NullOr(Schema.Number),
542 rendererPidSource: Schema.String,
543 rendererCreationTimeMs: Schema.NullOr(Schema.Number),
544 memory: Schema.NullOr(
545 Schema.Struct({
546 kind: Schema.String,
547 sampledAtUnixMs: Schema.Number,
548 workingSetBytes: Schema.Number,
549 peakWorkingSetBytes: Schema.Number,
550 }),
551 ),
552 reason: Schema.optional(Schema.String),
553 exitCode: Schema.optional(Schema.Number),
554 }),
555 );
556 const decodeRecord = Schema.decodeUnknownEffect(recordSchema);
557 type Record = typeof recordSchema.Type;
558 const persisted: Record[] = [];
559 let milestone:
560 | {
561 readonly matches: (record: Record) => boolean;
562 readonly written: Deferred.Deferred<Record>;
563 }
564 | undefined;
565 const layerFile = Layer.succeed(FileSystem.FileSystem, {
566 ...fileSystem,
567 writeFile: (filePath, bytes, options) =>
568 fileSystem.writeFile(filePath, bytes, options).pipe(
569 Effect.tap(() =>
570 decodeRecord(new TextDecoder().decode(bytes).trim()).pipe(
571 Effect.orDie,
572 Effect.flatMap((record) =>
573 Effect.sync(() => {
574 persisted.push(record);
575 if (milestone?.matches(record)) {
576 Deferred.doneUnsafe(milestone.written, Effect.succeed(record));
577 }
578 }),
579 ),
580 ),
581 ),
582 ),
583 });
584 const layer = DesktopRendererHistory.layer.pipe(
585 Layer.provide(
586 Layer.mergeAll(
587 Layer.succeed(
588 DesktopEnvironment.DesktopEnvironment,
589 DesktopEnvironment.DesktopEnvironment.of({
590 logDir: directory,
591 } as DesktopEnvironment.DesktopEnvironment["Service"]),
592 ),
593 layerFile,
594 ),
595 ),
596 );
597
598 yield* Effect.gen(function* () {
599 const history = yield* DesktopRendererHistory.DesktopRendererHistory;
600 yield* history.register(main as unknown as Electron.WebContents, { surface: "main" });
601 yield* history.register(preview as unknown as Electron.WebContents, {
602 surface: "preview",
603 });
604 yield* history.register(preview as unknown as Electron.WebContents, {
605 surface: "preview",
606 tabId: "preview-tab",
607 });
608 const sampled = yield* Deferred.make<Record>();
609 milestone = {
610 matches: (row) => row.event === "sample" && row.webContentsId === 2,
611 written: sampled,
612 };
613 yield* TestClock.adjust(Duration.seconds(30));
614 yield* history.recordMetrics(metrics);
615 const sample = yield* Deferred.await(sampled);
616 assert.equal(sample.rendererPid, 7_002);
617 assert.equal(sample.rendererCreationTimeMs, 700_200);
618 assert.equal(sample.tabId, "preview-tab");
619 assert.equal(sample.mainPid, process.pid);
620 assert.equal(sample.memory?.kind, "electron-process-working-set");
621 assert.equal(sample.memory?.workingSetBytes, 2_048 * 1_024);
622 assert.equal(sample.memory?.peakWorkingSetBytes, 4_096 * 1_024);
623 assert.equal(sample.memory?.sampledAtUnixMs, sample.timestampUnixMs);
624 assert.isTrue(
625 persisted.some(
626 (row) =>
627 row.event === "created" && row.surface === "main" && row.rendererPid === 7_001,
628 ),
629 );
630 assert.isTrue(
631 persisted.some((row) => row.event === "identified" && row.tabId === "preview-tab"),
632 );
633
634 const replaced = yield* Deferred.make<Record>();
635 milestone = { matches: (row) => row.event === "dom-ready", written: replaced };
636 previewPid = 7_003;
637 preview.emit("dom-ready");
638 const replacement = yield* Deferred.await(replaced);
639 assert.equal(replacement.rendererPid, 7_003);
640 assert.isNull(replacement.rendererCreationTimeMs);
641 assert.isNull(replacement.memory);
642 metrics[1] = { ...metrics[1]!, pid: 7_003, creationTime: 700_300 };
643 const replacementSampled = yield* Deferred.make<Record>();
644 milestone = {
645 matches: (row) => row.event === "sample" && row.webContentsId === 2,
646 written: replacementSampled,
647 };
648 yield* TestClock.adjust(Duration.seconds(30));
649 yield* history.recordMetrics(metrics);
650 assert.equal((yield* Deferred.await(replacementSampled)).rendererCreationTimeMs, 700_300);
651
652 const crashed = yield* Deferred.make<Record>();
653 milestone = { matches: (row) => row.event === "render-process-gone", written: crashed };
654 previewPid = 0;
655 preview.emit("render-process-gone", {}, { reason: "oom", exitCode: -7 });
656 const crash = yield* Deferred.await(crashed);
657 assert.equal(crash.rendererPid, 7_003);
658 assert.equal(crash.rendererPidSource, "last-known");
659 assert.equal(crash.reason, "oom");
660 assert.equal(crash.exitCode, -7);
661 // A restarted renderer can reuse the same numeric PID.
662 const restarted = yield* Deferred.make<Record>();
663 milestone = { matches: (row) => row.event === "dom-ready", written: restarted };
664 previewPid = 7_003;
665 preview.emit("dom-ready");
666 const restart = yield* Deferred.await(restarted);
667 assert.equal(restart.rendererPid, 7_003);
668 assert.equal(restart.rendererPidSource, "current");
669 assert.isNull(restart.rendererCreationTimeMs);
670 assert.isNull(restart.memory);
671 const destroyed = yield* Deferred.make<Record>();
672 milestone = { matches: (row) => row.event === "destroyed", written: destroyed };
673 previewDestroyed = true;
674 preview.emit("destroyed");
675 const destruction = yield* Deferred.await(destroyed);
676 assert.equal(destruction.rendererPid, 7_003);
677 assert.isNull(destruction.rendererCreationTimeMs);
678 assert.equal(destruction.tabId, "preview-tab");
679 assert.equal(preview.eventNames().length, 0);
680 const afterDestroy = yield* Deferred.make<Record>();
681 milestone = { matches: (row) => row.event === "sample", written: afterDestroy };
682 const previousCount = persisted.length;
683 yield* TestClock.adjust(Duration.seconds(30));
684 yield* history.recordMetrics(metrics);
685 assert.equal((yield* Deferred.await(afterDestroy)).webContentsId, 1);
686 assert.isFalse(persisted.slice(previousCount).some((row) => row.webContentsId === 2));
687 // The app quit handshake drains before the history layer closes.
688 for (let index = 0; index < 12; index++) main.emit("dom-ready");
689 yield* history.shutdown;
690 assert.equal(
691 persisted.filter((row) => row.webContentsId === 1 && row.event === "dom-ready").length,
692 12,
693 );
694 const afterShutdown = persisted.length;
695 yield* history.register(main as unknown as Electron.WebContents, { surface: "main" });
696 yield* history.recordMetrics(metrics);
697 assert.equal(persisted.length, afterShutdown);
698 assert.equal(main.eventNames().length, 0);
699 yield* history.shutdown;
700 }).pipe(Effect.provide(layer));
701
702 assert.equal(main.eventNames().length, 0);
703 assert.equal(
704 persisted.filter((row) => row.webContentsId === 1 && row.event === "dom-ready").length,
705 12,
706 );
707 assert.equal(urlReads, 0);
708 const contents = yield* fileSystem.readFileString(`${directory}/renderer-history.ndjson`);
709 assert.isFalse(contents.includes("private.invalid"));
710 assert.isFalse(contents.includes("secret"));
711 }).pipe(Effect.provide(NodeServices.layer)),
712 );
713
714 it.effect("rotates retained incident history within three 256 kib files", () =>
715 Effect.gen(function* () {
716 const fileSystem = yield* FileSystem.FileSystem;
717 const directory = yield* fileSystem.makeTempDirectoryScoped({
718 prefix: "t3-renderer-rotation-",
719 });
720 const filePath = `${directory}/renderer-history.ndjson`;
721 const maxBytes = 256 * 1_024;
722 const encodeSeed = Schema.encodeEffect(
723 Schema.fromJsonString(Schema.Struct({ seed: Schema.String })),
724 );
725 for (const [suffix, seed] of [
726 ["", "current"],
727 [".1", "previous"],
728 [".2", "expired"],
729 [".3", "overflow"],
730 ] as const) {
731 const encodedSeed = yield* encodeSeed({ seed });
732 yield* fileSystem.writeFileString(
733 `${filePath}${suffix}`,
734 `${encodedSeed.padEnd(maxBytes - 1, " ")}\n`,
735 );
736 }
737 const written = yield* Deferred.make<void>();
738 const layerFile = Layer.succeed(FileSystem.FileSystem, {
739 ...fileSystem,
740 writeFile: (target, bytes, options) =>
741 fileSystem
742 .writeFile(target, bytes, options)
743 .pipe(Effect.tap(() => Deferred.succeed(written, undefined))),
744 });
745 const layer = DesktopRendererHistory.layer.pipe(
746 Layer.provide(
747 Layer.mergeAll(
748 Layer.succeed(
749 DesktopEnvironment.DesktopEnvironment,
750 DesktopEnvironment.DesktopEnvironment.of({
751 logDir: directory,
752 } as DesktopEnvironment.DesktopEnvironment["Service"]),
753 ),
754 layerFile,
755 ),
756 ),
757 );
758 const renderer = Object.assign(new NodeEvents.EventEmitter(), {
759 id: 7,
760 getOSProcessId: () => 9_001,
761 isDestroyed: () => false,
762 });
763 yield* Effect.gen(function* () {
764 const history = yield* DesktopRendererHistory.DesktopRendererHistory;
765 yield* history.register(renderer as unknown as Electron.WebContents, {
766 surface: "preview",
767 tabId: "t".repeat(1_000),
768 });
769 yield* Deferred.await(written);
770 }).pipe(Effect.provide(layer));
771 assert.deepEqual((yield* fileSystem.readDirectory(directory)).sort(), [
772 "renderer-history.ndjson",
773 "renderer-history.ndjson.1",
774 "renderer-history.ndjson.2",
775 ]);
776 let totalBytes = 0;
777 for (const suffix of ["", ".1", ".2"]) {
778 const size = Number((yield* fileSystem.stat(`${filePath}${suffix}`)).size);
779 assert.isAtMost(size, maxBytes);
780 totalBytes += size;
781 }
782 assert.isAtMost(totalBytes, 3 * maxBytes);
783 assert.include(yield* fileSystem.readFileString(`${filePath}.1`), '"seed":"current"');
784 assert.include(yield* fileSystem.readFileString(`${filePath}.2`), '"seed":"previous"');
785 const current = yield* fileSystem.readFileString(filePath);
786 assert.include(current, `"tabId":"${"t".repeat(128)}"`);
787 assert.notInclude(current, "t".repeat(129));
788 }).pipe(Effect.provide(NodeServices.layer)),
789 );
790});