authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-05 12:04:42-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 12:04:42-03:00
log7b797f2b90918e7bae0f710dcf24018b8b0843dc
treed99e2b3a853f18e79c05146e258f2af5b1c9c513
parent1604ccc9d79f5270fb8e14d60664184bf142c4cb
signature Signed by PGP key B5690EEEBB952194

fix(desktop): bound preview inspector retention and record renderer identity (#16032)


10 files changed, 815 insertions(+), 36 deletions(-)

apps/desktop/src/app/DesktopApp.ts+6-1
...@@ -33,6 +33,7 @@ import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts";...@@ -33,6 +33,7 @@ import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts";
33import * as DesktopUpdates from "../updates/DesktopUpdates.ts";33import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
34import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts";34import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts";
35import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";35import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";
36import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
3637
37const DEFAULT_DESKTOP_BACKEND_PORT = 3773;38const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
38const MAX_TCP_PORT = 65_535;39const MAX_TCP_PORT = 65_535;
...@@ -342,6 +343,7 @@ const scopedProgram = Effect.scoped(...@@ -342,6 +343,7 @@ const scopedProgram = Effect.scoped(
342 yield* Effect.annotateCurrentSpan({ scope: "desktop", runId });343 yield* Effect.annotateCurrentSpan({ scope: "desktop", runId });
343344
344 const shutdown = yield* DesktopShutdown.DesktopShutdown;345 const shutdown = yield* DesktopShutdown.DesktopShutdown;
346 const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory;
345347
346 yield* Effect.addFinalizer(() =>348 yield* Effect.addFinalizer(() =>
347 // Stop every backend in the pool, not just the primary. The349 // Stop every backend in the pool, not just the primary. The
...@@ -349,7 +351,10 @@ const scopedProgram = Effect.scoped(...@@ -349,7 +351,10 @@ const scopedProgram = Effect.scoped(
349 // cascade, so leaving the WSL instance for its parent scope351 // cascade, so leaving the WSL instance for its parent scope
350 // finalizer means it gets hard-killed by the OS instead of352 // finalizer means it gets hard-killed by the OS instead of
351 // receiving SIGTERM + grace.353 // receiving SIGTERM + grace.
352 stopAllPoolInstances().pipe(Effect.ensuring(shutdown.markComplete)),354 stopAllPoolInstances().pipe(
355 Effect.ensuring(rendererHistory.shutdown),
356 Effect.ensuring(shutdown.markComplete),
357 ),
353 );358 );
354359
355 yield* startup;360 yield* startup;
apps/desktop/src/app/DesktopObservability.ts+1-1
...@@ -227,7 +227,7 @@ const refreshFileSize = (...@@ -227,7 +227,7 @@ const refreshFileSize = (
227 Effect.orElseSucceed(() => 0),227 Effect.orElseSucceed(() => 0),
228 );228 );
229229
230const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: {230export const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: {
231 readonly filePath: string;231 readonly filePath: string;
232 readonly maxBytes?: number;232 readonly maxBytes?: number;
233 readonly maxFiles?: number;233 readonly maxFiles?: number;
apps/desktop/src/main.ts+2
...@@ -62,6 +62,7 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";...@@ -62,6 +62,7 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
62import * as DesktopState from "./app/DesktopState.ts";62import * as DesktopState from "./app/DesktopState.ts";
63import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts";63import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts";
64import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";64import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
65import * as DesktopRendererHistory from "./telemetry/DesktopRendererHistory.ts";
65import * as DesktopUpdates from "./updates/DesktopUpdates.ts";66import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
66import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";67import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
67import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";68import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";
...@@ -147,6 +148,7 @@ const desktopFoundationLayer = Layer.mergeAll(...@@ -147,6 +148,7 @@ const desktopFoundationLayer = Layer.mergeAll(
147 DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)),148 DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)),
148 DesktopAssets.layer,149 DesktopAssets.layer,
149 DesktopObservability.layer,150 DesktopObservability.layer,
151 DesktopRendererHistory.layer,
150).pipe(Layer.provideMerge(desktopEnvironmentLayer));152).pipe(Layer.provideMerge(desktopEnvironmentLayer));
151153
152const desktopSshLayer = desktopSshEnvironmentLayer.pipe(154const desktopSshLayer = desktopSshEnvironmentLayer.pipe(
apps/desktop/src/preview/Manager.test.ts+90
...@@ -22,6 +22,7 @@ import { TestClock } from "effect/testing";...@@ -22,6 +22,7 @@ import { TestClock } from "effect/testing";
22import { beforeEach, describe, expect, it, vi } from "vite-plus/test";22import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2323
24import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";24import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
25import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
25import * as ElectronWindow from "../electron/ElectronWindow.ts";26import * as ElectronWindow from "../electron/ElectronWindow.ts";
26import * as BrowserSession from "./BrowserSession.ts";27import * as BrowserSession from "./BrowserSession.ts";
27import * as PreviewManager from "./Manager.ts";28import * as PreviewManager from "./Manager.ts";
...@@ -267,6 +268,13 @@ const fileSystemLayer = FileSystem.layerNoop({...@@ -267,6 +268,13 @@ const fileSystemLayer = FileSystem.layerNoop({
267});268});
268269
269const layer = PreviewManager.layer.pipe(270const layer = PreviewManager.layer.pipe(
271 Layer.provideMerge(
272 Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, {
273 register: () => Effect.void,
274 recordMetrics: () => Effect.void,
275 shutdown: Effect.void,
276 }),
277 ),
270 Layer.provideMerge(browserSessionLayer),278 Layer.provideMerge(browserSessionLayer),
271 Layer.provideMerge(environmentLayer),279 Layer.provideMerge(environmentLayer),
272 Layer.provideMerge(fileSystemLayer),280 Layer.provideMerge(fileSystemLayer),
...@@ -4613,6 +4621,88 @@ describe("PreviewManager", () => {...@@ -4613,6 +4621,88 @@ describe("PreviewManager", () => {
4613 ),4621 ),
4614 );4622 );
46154623
4624 effectIt.effect(
4625 "releases evaluation objects after success, exceptions, takeover, and cancellation",
4626 () =>
4627 withManager((manager) =>
4628 Effect.gen(function* () {
4629 const retained = new Map<string, number>();
4630 const pendingEvaluation = yield* Deferred.make<void>();
4631 const lateRelease = yield* Deferred.make<void>();
4632 let finishEvaluation: (() => void) | undefined;
4633 let cancelledGroup: string | undefined;
4634 let humanInput: ((event: unknown, signal: unknown) => void) | undefined;
4635 const wc = makeTestPreviewWebContents(vi.fn());
4636 Object.assign(wc, { isDevToolsOpened: () => false });
4637 Object.assign(wc.ipc, {
4638 on: vi.fn((channel: string, listener: typeof humanInput) => {
4639 if (channel === "preview:human-input") humanInput = listener;
4640 }),
4641 });
4642 Object.assign(wc.debugger, {
4643 sendCommand: vi.fn(async (method: string, params?: Record<string, unknown>) => {
4644 const group = String(params?.objectGroup ?? "");
4645 if (method === "Runtime.releaseObjectGroup") {
4646 retained.delete(group);
4647 if (group === cancelledGroup) Deferred.doneUnsafe(lateRelease, Effect.void);
4648 }
4649 if (method !== "Runtime.evaluate") return undefined;
4650 if (params?.expression === "cancelled") {
4651 cancelledGroup = group;
4652 Deferred.doneUnsafe(pendingEvaluation, Effect.void);
4653 await new Promise<void>((resolve) => {
4654 finishEvaluation = resolve;
4655 });
4656 }
4657 retained.set(group, 2);
4658 if (params?.expression === "exception") {
4659 return {
4660 result: { objectId: "exception-result" },
4661 exceptionDetails: {
4662 text: "failure",
4663 exception: { objectId: "exception-detail" },
4664 },
4665 };
4666 }
4667 if (params?.expression === "takeover") {
4668 humanInput?.({}, { kind: "pointer", x: 1, y: 2, button: 0 });
4669 }
4670 return { result: { value: 42 } };
4671 }),
4672 });
4673 fromId.mockReturnValue(wc);
4674 yield* manager.createTab("tab_1");
4675 yield* manager.registerWebview("tab_1", 42);
4676
4677 expect(yield* manager.automationEvaluate("tab_1", { expression: "success" })).toBe(42);
4678 expect(retained.size).toBe(0);
4679 const failure = yield* Effect.exit(
4680 manager.automationEvaluate("tab_1", { expression: "exception" }),
4681 );
4682 expect(Exit.isFailure(failure)).toBe(true);
4683 expect(retained.size).toBe(0);
4684 const takeover = yield* Effect.exit(
4685 manager.automationEvaluate("tab_1", { expression: "takeover" }),
4686 );
4687 expect(Exit.isFailure(takeover)).toBe(true);
4688 if (Exit.isFailure(takeover)) {
4689 expect(Option.getOrThrow(Cause.findErrorOption(takeover.cause))).toMatchObject({
4690 _tag: "PreviewAutomationControlInterruptedError",
4691 });
4692 }
4693 expect(retained.size).toBe(0);
4694 const cancelled = yield* manager
4695 .automationEvaluate("tab_1", { expression: "cancelled" })
4696 .pipe(Effect.forkChild({ startImmediately: true }));
4697 yield* Deferred.await(pendingEvaluation);
4698 yield* Fiber.interrupt(cancelled);
4699 finishEvaluation?.();
4700 yield* Deferred.await(lateRelease);
4701 expect(retained.size).toBe(0);
4702 }),
4703 ),
4704 );
4705
4616 effectIt.effect("derives evaluation detail kind and length from the same non-empty source", () =>4706 effectIt.effect("derives evaluation detail kind and length from the same non-empty source", () =>
4617 withManager((manager) =>4707 withManager((manager) =>
4618 Effect.gen(function* () {4708 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+82-26
...@@ -58,6 +58,7 @@ import * as Fiber from "effect/Fiber";...@@ -58,6 +58,7 @@ import * as Fiber from "effect/Fiber";
58import * as Layer from "effect/Layer";58import * as Layer from "effect/Layer";
59import * as Option from "effect/Option";59import * as Option from "effect/Option";
60import * as Path from "effect/Path";60import * as Path from "effect/Path";
61import * as Queue from "effect/Queue";
61import * as Ref from "effect/Ref";62import * as Ref from "effect/Ref";
62import * as Schema from "effect/Schema";63import * as Schema from "effect/Schema";
63import * as Semaphore from "effect/Semaphore";64import * as Semaphore from "effect/Semaphore";
...@@ -66,6 +67,7 @@ import * as Scope from "effect/Scope";...@@ -66,6 +67,7 @@ import * as Scope from "effect/Scope";
66import * as SynchronizedRef from "effect/SynchronizedRef";67import * as SynchronizedRef from "effect/SynchronizedRef";
6768
68import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";69import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
70import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
69import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";71import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";
70import * as BrowserSession from "./BrowserSession.ts";72import * as BrowserSession from "./BrowserSession.ts";
71import {73import {
...@@ -638,6 +640,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -638,6 +640,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
638 pictureInPicturePreloadPath: string,640 pictureInPicturePreloadPath: string,
639) {641) {
640 const fileSystem = yield* FileSystem.FileSystem;642 const fileSystem = yield* FileSystem.FileSystem;
643 const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory;
641 const hostPlatform = yield* HostProcessPlatform;644 const hostPlatform = yield* HostProcessPlatform;
642 const path = yield* Path.Path;645 const path = yield* Path.Path;
643 const parentScope = yield* Scope.Scope;646 const parentScope = yield* Scope.Scope;
...@@ -1332,6 +1335,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1332,6 +1335,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1332 const semaphore = yield* Semaphore.make(1);1335 const semaphore = yield* Semaphore.make(1);
1333 const scope = yield* Scope.fork(parentScope, "sequential");1336 const scope = yield* Scope.fork(parentScope, "sequential");
1334 const wcDebugger = wc.debugger;1337 const wcDebugger = wc.debugger;
1338 const consoleReleases = yield* Queue.sliding<void>(1);
1339 // Console message eviction does not release the debugger's strong
1340 // object handles. We keep text only, so release the whole group,
1341 // including its object-id bookkeeping. Coalesce bursts behind one
1342 // command rather than queueing a command for every logged object.
1343 yield* Effect.forkIn(
1344 Effect.forever(
1345 Queue.take(consoleReleases).pipe(
1346 Effect.andThen(
1347 attemptPromise({ operation: "releaseConsoleObjects", webContentsId: wc.id }, () =>
1348 wcDebugger.sendCommand("Runtime.releaseObjectGroup", {
1349 objectGroup: "console",
1350 }),
1351 ).pipe(Effect.ignore),
1352 ),
1353 ),
1354 ),
1355 scope,
1356 );
1335 const handleDebuggerMessage = Effect.fnUntraced(function* (1357 const handleDebuggerMessage = Effect.fnUntraced(function* (
1336 method: string,1358 method: string,
1337 params: Record<string, unknown>,1359 params: Record<string, unknown>,
...@@ -1378,6 +1400,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1378,6 +1400,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1378 }1400 }
1379 }1401 }
1380 yield* captureDiagnosticMessage(wc.id, method, params);1402 yield* captureDiagnosticMessage(wc.id, method, params);
1403 if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") {
1404 yield* Queue.offer(consoleReleases, undefined);
1405 }
1381 });1406 });
1382 const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {1407 const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {
1383 runFork(handleDebuggerMessage(method, params));1408 runFork(handleDebuggerMessage(method, params));
...@@ -1540,10 +1565,31 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1540,10 +1565,31 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1540 yield* checkControl;1565 yield* checkControl;
1541 const result = yield* attemptPromise(1566 const result = yield* attemptPromise(
1542 { operation: `${action}.${method}`, tabId, webContentsId: wc.id },1567 { operation: `${action}.${method}`, tabId, webContentsId: wc.id },
1543 () =>1568 async () => {
1544 sessionId === undefined1569 try {
1545 ? control.debugger.sendCommand(method, commandParams)1570 return await (sessionId === undefined
1546 : control.debugger.sendCommand(method, commandParams, sessionId),1571 ? control.debugger.sendCommand(method, commandParams)
1572 : control.debugger.sendCommand(method, commandParams, sessionId));
1573 } finally {
1574 const objectGroup =
1575 method === "Runtime.evaluate" ? commandParams?.["objectGroup"] : undefined;
1576 // Cancelling an Effect does not cancel the CDP promise. Release
1577 // after it settles, even if the caller has already left, so a
1578 // late result cannot recreate handles after an early cleanup.
1579 if (typeof objectGroup === "string" && objectGroup.startsWith("t3-evaluation-")) {
1580 const params = { objectGroup };
1581 await (
1582 sessionId === undefined
1583 ? control.debugger.sendCommand("Runtime.releaseObjectGroup", params)
1584 : control.debugger.sendCommand(
1585 "Runtime.releaseObjectGroup",
1586 params,
1587 sessionId,
1588 )
1589 ).catch(() => undefined);
1590 }
1591 }
1592 },
1547 );1593 );
1548 yield* checkControl;1594 yield* checkControl;
1549 return result;1595 return result;
...@@ -1611,28 +1657,32 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1611,28 +1657,32 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1611 returnByValue: boolean,1657 returnByValue: boolean,
1612 awaitPromise = true,1658 awaitPromise = true,
1613 ): Effect.Effect<A, PreviewManagerError> =>1659 ): Effect.Effect<A, PreviewManagerError> =>
1614 send("Runtime.evaluate", {1660 Effect.suspend(() => {
1615 expression,1661 const objectGroup = `t3-evaluation-${NodeCrypto.randomUUID()}`;
1616 awaitPromise,1662 return send("Runtime.evaluate", {
1617 returnByValue,1663 expression,
1618 userGesture: true,1664 awaitPromise,
1619 }).pipe(1665 returnByValue,
1620 Effect.flatMap((rawResponse) => {1666 userGesture: true,
1621 const response = rawResponse as CdpEvaluationResult;1667 objectGroup,
1622 if (!response.exceptionDetails) {1668 }).pipe(
1623 return Effect.succeed(response.result?.value as A);1669 Effect.flatMap((rawResponse) => {
1624 }1670 const response = rawResponse as CdpEvaluationResult;
1625 const detail = previewAutomationEvaluationDetail(response.exceptionDetails);1671 if (!response.exceptionDetails) {
1626 return Effect.fail(1672 return Effect.succeed(response.result?.value as A);
1627 new PreviewAutomationEvaluationError({1673 }
1628 tabId,1674 const detail = previewAutomationEvaluationDetail(response.exceptionDetails);
1629 detailKind: detail.detailKind,1675 return Effect.fail(
1630 detailLength: detail.detail?.length ?? 0,1676 new PreviewAutomationEvaluationError({
1631 cause: response.exceptionDetails,1677 tabId,
1632 }),1678 detailKind: detail.detailKind,
1633 );1679 detailLength: detail.detail?.length ?? 0,
1634 }),1680 cause: response.exceptionDetails,
1635 );1681 }),
1682 );
1683 }),
1684 );
1685 });
16361686
1637 const automationLocator = (input: {1687 const automationLocator = (input: {
1638 readonly selector?: string | undefined;1688 readonly selector?: string | undefined;
...@@ -2281,6 +2331,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2281,6 +2331,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2281 ) {2331 ) {
2282 return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId });2332 return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId });
2283 }2333 }
2334 yield* rendererHistory.register(wc, { surface: "preview", tabId });
2284 const attached = yield* Ref.get(attachedRef);2335 const attached = yield* Ref.get(attachedRef);
2285 const annotationTheme = yield* Ref.get(annotationThemeRef);2336 const annotationTheme = yield* Ref.get(annotationThemeRef);
2286 const currentAttachment = attached.get(webContentsId);2337 const currentAttachment = attached.get(webContentsId);
...@@ -2435,6 +2486,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2435,6 +2486,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2435 const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* (2486 const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* (
2436 wc: Electron.WebContents,2487 wc: Electron.WebContents,
2437 ) {2488 ) {
2489 yield* rendererHistory.register(wc, { surface: "preview" });
2438 const webContentsId = wc.id;2490 const webContentsId = wc.id;
2439 // A guest destroyed before any tab claims it has no other cleanup path.2491 // A guest destroyed before any tab claims it has no other cleanup path.
2440 wc.once("destroyed", () => {2492 wc.once("destroyed", () => {
...@@ -3314,6 +3366,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -3314,6 +3366,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
3314 },3366 },
3315 }),3367 }),
3316 );3368 );
3369 yield* rendererHistory.register(pictureInPictureWindow.webContents, {
3370 surface: "picture-in-picture",
3371 tabId,
3372 });
3317 const initializationScope = yield* Scope.fork(parentScope, "sequential");3373 const initializationScope = yield* Scope.fork(parentScope, "sequential");
3318 const ready = yield* Deferred.make<void, PreviewManagerError>();3374 const ready = yield* Deferred.make<void, PreviewManagerError>();
3319 const session: PictureInPictureSession = {3375 const session: PictureInPictureSession = {
apps/desktop/src/telemetry/DesktopRendererHistory.ts created+283
...@@ -0,0 +1,283 @@
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());
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+328-6
...@@ -1,10 +1,12 @@...@@ -1,10 +1,12 @@
1import { DesktopHostTelemetryMessage } from "@t3tools/contracts";1import { DesktopHostTelemetryMessage } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";3import { assert, describe, it } from "@effect/vitest";
3import * as Deferred from "effect/Deferred";4import * as Deferred from "effect/Deferred";
4import * as Duration from "effect/Duration";5import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";7import * as Exit from "effect/Exit";
7import * as Fiber from "effect/Fiber";8import * as Fiber from "effect/Fiber";
9import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";11import * as Option from "effect/Option";
10import * as Ref from "effect/Ref";12import * as Ref from "effect/Ref";
...@@ -14,10 +16,19 @@ import * as Stream from "effect/Stream";...@@ -14,10 +16,19 @@ import * as Stream from "effect/Stream";
14import * as TestClock from "effect/testing/TestClock";16import * as TestClock from "effect/testing/TestClock";
1517
16import type * as Electron from "electron";18import type * as Electron from "electron";
19import * as NodeEvents from "node:events";
1720
21import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
18import * as ElectronApp from "../electron/ElectronApp.ts";22import * as ElectronApp from "../electron/ElectronApp.ts";
19import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts";23import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts";
20import * as DesktopTelemetryPublisher from "./DesktopTelemetryPublisher.ts";24import * as DesktopTelemetryPublisher from "./DesktopTelemetryPublisher.ts";
25import * as DesktopRendererHistory from "./DesktopRendererHistory.ts";
26
27const historyLayer = Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, {
28 register: () => Effect.void,
29 recordMetrics: () => Effect.void,
30 shutdown: Effect.void,
31});
2132
22function makeElectronAppLayer(33function makeElectronAppLayer(
23 metrics: ReadonlyArray<Electron.ProcessMetric>,34 metrics: ReadonlyArray<Electron.ProcessMetric>,
...@@ -70,7 +81,7 @@ describe("DesktopTelemetryPublisher", () => {...@@ -70,7 +81,7 @@ describe("DesktopTelemetryPublisher", () => {
70 }),81 }),
71 );82 );
72 const layer = DesktopTelemetryPublisher.layer.pipe(83 const layer = DesktopTelemetryPublisher.layer.pipe(
73 Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer)),84 Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer, historyLayer)),
74 );85 );
75 const scope = yield* Scope.make();86 const scope = yield* Scope.make();
7687
...@@ -91,6 +102,7 @@ describe("DesktopTelemetryPublisher", () => {...@@ -91,6 +102,7 @@ describe("DesktopTelemetryPublisher", () => {
91 const systemIdleState = yield* Ref.make<ElectronPowerMonitor.ElectronIdleState>("active");102 const systemIdleState = yield* Ref.make<ElectronPowerMonitor.ElectronIdleState>("active");
92 let beforeSystemIdleState: Effect.Effect<void> = Effect.void;103 let beforeSystemIdleState: Effect.Effect<void> = Effect.void;
93 let metricsReadCount = 0;104 let metricsReadCount = 0;
105 const recordedMetrics: ReadonlyArray<Electron.ProcessMetric>[] = [];
94 const simpleListeners = new Map<string, () => void>();106 const simpleListeners = new Map<string, () => void>();
95 let thermalListener: ((state: ElectronPowerMonitor.ElectronThermalState) => void) | null =107 let thermalListener: ((state: ElectronPowerMonitor.ElectronThermalState) => void) | null =
96 null;108 null;
...@@ -141,6 +153,14 @@ describe("DesktopTelemetryPublisher", () => {...@@ -141,6 +153,14 @@ describe("DesktopTelemetryPublisher", () => {
141 metricsReadCount += 1;153 metricsReadCount += 1;
142 }),154 }),
143 powerLayer,155 powerLayer,
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 }),
144 ),164 ),
145 ),165 ),
146 );166 );
...@@ -167,7 +187,8 @@ describe("DesktopTelemetryPublisher", () => {...@@ -167,7 +187,8 @@ describe("DesktopTelemetryPublisher", () => {
167 }187 }
168 assert.deepEqual(initialSnapshot.electronProcesses, []);188 assert.deepEqual(initialSnapshot.electronProcesses, []);
169 assert.equal(initialSnapshot.electronPid, process.pid);189 assert.equal(initialSnapshot.electronPid, process.pid);
170 assert.equal(metricsReadCount, 0);190 assert.equal(metricsReadCount, 1);
191 assert.deepEqual(recordedMetrics, [metrics]);
171192
172 const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild);193 const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild);
173 yield* Effect.yieldNow;194 yield* Effect.yieldNow;
...@@ -181,7 +202,8 @@ describe("DesktopTelemetryPublisher", () => {...@@ -181,7 +202,8 @@ describe("DesktopTelemetryPublisher", () => {
181 assert.equal(demandedSnapshot.electronProcesses[0]?.creationTimeMs, 1_001);202 assert.equal(demandedSnapshot.electronProcesses[0]?.creationTimeMs, 1_001);
182 assert.equal(demandedSnapshot.electronProcesses[0]?.cpuPercent, 12.5);203 assert.equal(demandedSnapshot.electronProcesses[0]?.cpuPercent, 12.5);
183 assert.equal(demandedSnapshot.electronProcesses[0]?.workingSetBytes, 2_048 * 1_024);204 assert.equal(demandedSnapshot.electronProcesses[0]?.workingSetBytes, 2_048 * 1_024);
184 assert.equal(metricsReadCount, 1);205 assert.equal(metricsReadCount, 2);
206 assert.deepEqual(recordedMetrics, [metrics, metrics]);
185 yield* publisher.handleControlForSource("secondary-backend", {207 yield* publisher.handleControlForSource("secondary-backend", {
186 version: 1,208 version: 1,
187 type: "setDiagnosticsDemand",209 type: "setDiagnosticsDemand",
...@@ -322,13 +344,13 @@ describe("DesktopTelemetryPublisher", () => {...@@ -322,13 +344,13 @@ describe("DesktopTelemetryPublisher", () => {
322 (yield* publisher.latest).pipe(Option.getOrThrow).sequence,344 (yield* publisher.latest).pipe(Option.getOrThrow).sequence,
323 configuredSequence,345 configuredSequence,
324 );346 );
325 assert.equal(metricsReadCount, metricsAfterStopping);347 assert.equal(metricsReadCount, metricsAfterStopping + 1);
326 yield* TestClock.adjust(Duration.millis(1));348 yield* TestClock.adjust(Duration.millis(1));
327 assert.equal(349 assert.equal(
328 (yield* publisher.latest).pipe(Option.getOrThrow).sequence,350 (yield* publisher.latest).pipe(Option.getOrThrow).sequence,
329 configuredSequence + 1,351 configuredSequence + 1,
330 );352 );
331 assert.equal(metricsReadCount, metricsAfterStopping);353 assert.equal(metricsReadCount, metricsAfterStopping + 2);
332354
333 yield* Ref.set(systemIdleState, "locked");355 yield* Ref.set(systemIdleState, "locked");
334 yield* TestClock.adjust(Duration.seconds(7));356 yield* TestClock.adjust(Duration.seconds(7));
...@@ -404,7 +426,7 @@ describe("DesktopTelemetryPublisher", () => {...@@ -404,7 +426,7 @@ describe("DesktopTelemetryPublisher", () => {
404 }),426 }),
405 );427 );
406 const layer = DesktopTelemetryPublisher.layer.pipe(428 const layer = DesktopTelemetryPublisher.layer.pipe(
407 Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer)),429 Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer, historyLayer)),
408 );430 );
409431
410 yield* Effect.gen(function* () {432 yield* Effect.gen(function* () {
...@@ -466,3 +488,303 @@ describe("DesktopTelemetryPublisher", () => {...@@ -466,3 +488,303 @@ describe("DesktopTelemetryPublisher", () => {
466 }),488 }),
467 );489 );
468});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 fileLayer = 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 fileLayer,
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 fileLayer = 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 fileLayer,
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});
apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts+7-2
...@@ -23,6 +23,7 @@ import * as Stream from "effect/Stream";...@@ -23,6 +23,7 @@ import * as Stream from "effect/Stream";
2323
24import * as ElectronApp from "../electron/ElectronApp.ts";24import * as ElectronApp from "../electron/ElectronApp.ts";
25import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts";25import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts";
26import * as DesktopRendererHistory from "./DesktopRendererHistory.ts";
2627
27const LIVE_SAMPLE_INTERVAL = Duration.seconds(1);28const LIVE_SAMPLE_INTERVAL = Duration.seconds(1);
28const BATTERY_SAMPLE_INTERVAL = Duration.seconds(5);29const BATTERY_SAMPLE_INTERVAL = Duration.seconds(5);
...@@ -148,6 +149,7 @@ function sampleInterval(...@@ -148,6 +149,7 @@ function sampleInterval(
148export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {149export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
149 const electronApp = yield* ElectronApp.ElectronApp;150 const electronApp = yield* ElectronApp.ElectronApp;
150 const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor;151 const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor;
152 const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory;
151 yield* electronApp.whenReady;153 yield* electronApp.whenReady;
152154
153 const initialPowerState: PowerState = {155 const initialPowerState: PowerState = {
...@@ -212,10 +214,13 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {...@@ -212,10 +214,13 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
212 powerMonitor.getSystemIdleTime,214 powerMonitor.getSystemIdleTime,
213 powerMonitor.getSystemIdleState(IDLE_THRESHOLD_SECONDS),215 powerMonitor.getSystemIdleState(IDLE_THRESHOLD_SECONDS),
214 powerMonitor.isOnBatteryPower,216 powerMonitor.isOnBatteryPower,
215 demand ? electronApp.getAppMetrics : Effect.succeed([]),217 electronApp.getAppMetrics,
216 ],218 ],
217 { concurrency: "unbounded" },219 { concurrency: "unbounded" },
218 );220 );
221 // One Electron sampler preserves the shared CPU measurement interval.
222 // History follows the existing power-aware cadence, even without demand.
223 yield* rendererHistory.recordMetrics(metrics);
219 const polledLocked =224 const polledLocked =
220 systemIdleState === "unknown"225 systemIdleState === "unknown"
221 ? currentPower.locked226 ? currentPower.locked
...@@ -270,7 +275,7 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {...@@ -270,7 +275,7 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
270 updatedAt: sampledAt,275 updatedAt: sampledAt,
271 },276 },
272 speedLimitPercent: observedPower.speedLimitPercent,277 speedLimitPercent: observedPower.speedLimitPercent,
273 electronProcesses: metrics.map((metric) => ({278 electronProcesses: (demand ? metrics : []).map((metric) => ({
274 pid: metric.pid,279 pid: metric.pid,
275 creationTimeMs: Math.max(0, Math.round(metric.creationTime)),280 creationTimeMs: Math.max(0, Math.round(metric.creationTime)),
276 type: metric.type,281 type: metric.type,
apps/desktop/src/window/DesktopWindow.test.ts+11
...@@ -42,6 +42,7 @@ import * as DesktopState from "../app/DesktopState.ts";...@@ -42,6 +42,7 @@ import * as DesktopState from "../app/DesktopState.ts";
42import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";42import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
43import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";43import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
44import * as ElectronApp from "../electron/ElectronApp.ts";44import * as ElectronApp from "../electron/ElectronApp.ts";
45import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
45import * as ElectronMenu from "../electron/ElectronMenu.ts";46import * as ElectronMenu from "../electron/ElectronMenu.ts";
46import * as ElectronShell from "../electron/ElectronShell.ts";47import * as ElectronShell from "../electron/ElectronShell.ts";
47import * as ElectronTheme from "../electron/ElectronTheme.ts";48import * as ElectronTheme from "../electron/ElectronTheme.ts";
...@@ -286,6 +287,11 @@ function makeTestLayer(input: {...@@ -286,6 +287,11 @@ function makeTestLayer(input: {
286 Layer.provide(287 Layer.provide(
287 Layer.mergeAll(288 Layer.mergeAll(
288 desktopAssetsLayer,289 desktopAssetsLayer,
290 Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, {
291 register: () => Effect.void,
292 recordMetrics: () => Effect.void,
293 shutdown: Effect.void,
294 }),
289 desktopEnvironmentLayer,295 desktopEnvironmentLayer,
290 desktopAppSettingsLayer,296 desktopAppSettingsLayer,
291 desktopClientSettingsLayer,297 desktopClientSettingsLayer,
...@@ -401,6 +407,11 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n...@@ -401,6 +407,11 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
401 Layer.provide(407 Layer.provide(
402 Layer.mergeAll(408 Layer.mergeAll(
403 desktopAssetsLayer,409 desktopAssetsLayer,
410 Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, {
411 register: () => Effect.void,
412 recordMetrics: () => Effect.void,
413 shutdown: Effect.void,
414 }),
404 desktopEnvironmentLayer,415 desktopEnvironmentLayer,
405 DesktopAppSettings.layerTest(),416 DesktopAppSettings.layerTest(),
406 desktopClientSettingsLayer,417 desktopClientSettingsLayer,
apps/desktop/src/window/DesktopWindow.ts+5
...@@ -29,6 +29,7 @@ import * as PreviewManager from "../preview/Manager.ts";...@@ -29,6 +29,7 @@ import * as PreviewManager from "../preview/Manager.ts";
29import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";29import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
30import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";30import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
31import * as ElectronApp from "../electron/ElectronApp.ts";31import * as ElectronApp from "../electron/ElectronApp.ts";
32import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
32import { makeQuitShortcutHandler } from "./QuitHold.ts";33import { makeQuitShortcutHandler } from "./QuitHold.ts";
3334
34const TITLEBAR_HEIGHT = 40;35const TITLEBAR_HEIGHT = 40;
...@@ -84,6 +85,7 @@ type DesktopWindowRuntimeServices =...@@ -84,6 +85,7 @@ type DesktopWindowRuntimeServices =
84 | ElectronShell.ElectronShell85 | ElectronShell.ElectronShell
85 | ElectronTheme.ElectronTheme86 | ElectronTheme.ElectronTheme
86 | ElectronWindow.ElectronWindow87 | ElectronWindow.ElectronWindow
88 | DesktopRendererHistory.DesktopRendererHistory
87 | PreviewManager.PreviewManager;89 | PreviewManager.PreviewManager;
8890
89export type DesktopWindowError =91export type DesktopWindowError =
...@@ -322,6 +324,7 @@ export const make = Effect.gen(function* () {...@@ -322,6 +324,7 @@ export const make = Effect.gen(function* () {
322 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;324 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
323 const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;325 const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
324 const electronApp = yield* ElectronApp.ElectronApp;326 const electronApp = yield* ElectronApp.ElectronApp;
327 const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory;
325 // Window-side latch for the primary backend's readiness. Set by328 // Window-side latch for the primary backend's readiness. Set by
326 // handleBackendReady (driven by the pool's onReady callback), cleared329 // handleBackendReady (driven by the pool's onReady callback), cleared
327 // by handleBackendNotReady (driven by onShutdown). Only consumed by330 // by handleBackendNotReady (driven by onShutdown). Only consumed by
...@@ -420,6 +423,7 @@ export const make = Effect.gen(function* () {...@@ -420,6 +423,7 @@ export const make = Effect.gen(function* () {
420 },423 },
421 });424 });
422425
426 yield* rendererHistory.register(window.webContents, { surface: "main" });
423 if (environment.platform === "darwin") {427 if (environment.platform === "darwin") {
424 window.setAutoHideCursor(false);428 window.setAutoHideCursor(false);
425 }429 }
...@@ -905,6 +909,7 @@ export const make = Effect.gen(function* () {...@@ -905,6 +909,7 @@ export const make = Effect.gen(function* () {
905 sandbox: true,909 sandbox: true,
906 },910 },
907 });911 });
912 yield* rendererHistory.register(splash.webContents, { surface: "splash" });
908 yield* Ref.set(splashWindowRef, Option.some(splash));913 yield* Ref.set(splashWindowRef, Option.some(splash));
909 splash.once("closed", () => {914 splash.once("closed", () => {
910 void runPromise(Ref.set(splashWindowRef, Option.none()));915 void runPromise(Ref.set(splashWindowRef, Option.none()));