| author | |
| committer | |
| log | 7b797f2b90918e7bae0f710dcf24018b8b0843dc |
| tree | d99e2b3a853f18e79c05146e258f2af5b1c9c513 |
| parent | 1604ccc9d79f5270fb8e14d60664184bf142c4cb |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 33 | import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; |
| 34 | 34 | import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; |
| 35 | 35 | import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; |
| 36 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; | |
| 36 | 37 | |
| 37 | 38 | const DEFAULT_DESKTOP_BACKEND_PORT = 3773; |
| 38 | 39 | const MAX_TCP_PORT = 65_535; |
| ... | ... | @@ -342,6 +343,7 @@ const scopedProgram = Effect.scoped( |
| 342 | 343 | yield* Effect.annotateCurrentSpan({ scope: "desktop", runId }); |
| 343 | 344 | |
| 344 | 345 | const shutdown = yield* DesktopShutdown.DesktopShutdown; |
| 346 | const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; | |
| 345 | 347 | |
| 346 | 348 | yield* Effect.addFinalizer(() => |
| 347 | 349 | // Stop every backend in the pool, not just the primary. The |
| ... | ... | @@ -349,7 +351,10 @@ const scopedProgram = Effect.scoped( |
| 349 | 351 | // cascade, so leaving the WSL instance for its parent scope |
| 350 | 352 | // finalizer means it gets hard-killed by the OS instead of |
| 351 | 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 | ); |
| 354 | 359 | |
| 355 | 360 | yield* startup; |
apps/desktop/src/app/DesktopObservability.ts+1-1| ... | ... | @@ -227,7 +227,7 @@ const refreshFileSize = ( |
| 227 | 227 | Effect.orElseSucceed(() => 0), |
| 228 | 228 | ); |
| 229 | 229 | |
| 230 | const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { | |
| 230 | export const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { | |
| 231 | 231 | readonly filePath: string; |
| 232 | 232 | readonly maxBytes?: number; |
| 233 | 233 | readonly maxFiles?: number; |
apps/desktop/src/main.ts+2| ... | ... | @@ -62,6 +62,7 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; |
| 62 | 62 | import * as DesktopState from "./app/DesktopState.ts"; |
| 63 | 63 | import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts"; |
| 64 | 64 | import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; |
| 65 | import * as DesktopRendererHistory from "./telemetry/DesktopRendererHistory.ts"; | |
| 65 | 66 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 66 | 67 | import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; |
| 67 | 68 | import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; |
| ... | ... | @@ -147,6 +148,7 @@ const desktopFoundationLayer = Layer.mergeAll( |
| 147 | 148 | DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), |
| 148 | 149 | DesktopAssets.layer, |
| 149 | 150 | DesktopObservability.layer, |
| 151 | DesktopRendererHistory.layer, | |
| 150 | 152 | ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); |
| 151 | 153 | |
| 152 | 154 | const desktopSshLayer = desktopSshEnvironmentLayer.pipe( |
apps/desktop/src/preview/Manager.test.ts+90| ... | ... | @@ -22,6 +22,7 @@ import { TestClock } from "effect/testing"; |
| 22 | 22 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 23 | 23 | |
| 24 | 24 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 25 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; | |
| 25 | 26 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 26 | 27 | import * as BrowserSession from "./BrowserSession.ts"; |
| 27 | 28 | import * as PreviewManager from "./Manager.ts"; |
| ... | ... | @@ -267,6 +268,13 @@ const fileSystemLayer = FileSystem.layerNoop({ |
| 267 | 268 | }); |
| 268 | 269 | |
| 269 | 270 | const 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 | 278 | Layer.provideMerge(browserSessionLayer), |
| 271 | 279 | Layer.provideMerge(environmentLayer), |
| 272 | 280 | Layer.provideMerge(fileSystemLayer), |
| ... | ... | @@ -4613,6 +4621,88 @@ describe("PreviewManager", () => { |
| 4613 | 4621 | ), |
| 4614 | 4622 | ); |
| 4615 | 4623 | |
| 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 | 4706 | effectIt.effect("derives evaluation detail kind and length from the same non-empty source", () => |
| 4617 | 4707 | withManager((manager) => |
| 4618 | 4708 | Effect.gen(function* () { |
apps/desktop/src/preview/Manager.ts+82-26| ... | ... | @@ -58,6 +58,7 @@ import * as Fiber from "effect/Fiber"; |
| 58 | 58 | import * as Layer from "effect/Layer"; |
| 59 | 59 | import * as Option from "effect/Option"; |
| 60 | 60 | import * as Path from "effect/Path"; |
| 61 | import * as Queue from "effect/Queue"; | |
| 61 | 62 | import * as Ref from "effect/Ref"; |
| 62 | 63 | import * as Schema from "effect/Schema"; |
| 63 | 64 | import * as Semaphore from "effect/Semaphore"; |
| ... | ... | @@ -66,6 +67,7 @@ import * as Scope from "effect/Scope"; |
| 66 | 67 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 67 | 68 | |
| 68 | 69 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 70 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; | |
| 69 | 71 | import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; |
| 70 | 72 | import * as BrowserSession from "./BrowserSession.ts"; |
| 71 | 73 | import { |
| ... | ... | @@ -638,6 +640,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 638 | 640 | pictureInPicturePreloadPath: string, |
| 639 | 641 | ) { |
| 640 | 642 | const fileSystem = yield* FileSystem.FileSystem; |
| 643 | const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; | |
| 641 | 644 | const hostPlatform = yield* HostProcessPlatform; |
| 642 | 645 | const path = yield* Path.Path; |
| 643 | 646 | const parentScope = yield* Scope.Scope; |
| ... | ... | @@ -1332,6 +1335,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1332 | 1335 | const semaphore = yield* Semaphore.make(1); |
| 1333 | 1336 | const scope = yield* Scope.fork(parentScope, "sequential"); |
| 1334 | 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 | 1357 | const handleDebuggerMessage = Effect.fnUntraced(function* ( |
| 1336 | 1358 | method: string, |
| 1337 | 1359 | params: Record<string, unknown>, |
| ... | ... | @@ -1378,6 +1400,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1378 | 1400 | } |
| 1379 | 1401 | } |
| 1380 | 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 | 1407 | const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { |
| 1383 | 1408 | runFork(handleDebuggerMessage(method, params)); |
| ... | ... | @@ -1540,10 +1565,31 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1540 | 1565 | yield* checkControl; |
| 1541 | 1566 | const result = yield* attemptPromise( |
| 1542 | 1567 | { operation: `${action}.${method}`, tabId, webContentsId: wc.id }, |
| 1543 | () => | |
| 1544 | sessionId === undefined | |
| 1545 | ? control.debugger.sendCommand(method, commandParams) | |
| 1546 | : control.debugger.sendCommand(method, commandParams, sessionId), | |
| 1568 | async () => { | |
| 1569 | try { | |
| 1570 | return await (sessionId === undefined | |
| 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 | 1594 | yield* checkControl; |
| 1549 | 1595 | return result; |
| ... | ... | @@ -1611,28 +1657,32 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1611 | 1657 | returnByValue: boolean, |
| 1612 | 1658 | awaitPromise = true, |
| 1613 | 1659 | ): Effect.Effect<A, PreviewManagerError> => |
| 1614 | send("Runtime.evaluate", { | |
| 1615 | expression, | |
| 1616 | awaitPromise, | |
| 1617 | returnByValue, | |
| 1618 | userGesture: true, | |
| 1619 | }).pipe( | |
| 1620 | Effect.flatMap((rawResponse) => { | |
| 1621 | const response = rawResponse as CdpEvaluationResult; | |
| 1622 | if (!response.exceptionDetails) { | |
| 1623 | return Effect.succeed(response.result?.value as A); | |
| 1624 | } | |
| 1625 | const detail = previewAutomationEvaluationDetail(response.exceptionDetails); | |
| 1626 | return Effect.fail( | |
| 1627 | new PreviewAutomationEvaluationError({ | |
| 1628 | tabId, | |
| 1629 | detailKind: detail.detailKind, | |
| 1630 | detailLength: detail.detail?.length ?? 0, | |
| 1631 | cause: response.exceptionDetails, | |
| 1632 | }), | |
| 1633 | ); | |
| 1634 | }), | |
| 1635 | ); | |
| 1660 | Effect.suspend(() => { | |
| 1661 | const objectGroup = `t3-evaluation-${NodeCrypto.randomUUID()}`; | |
| 1662 | return send("Runtime.evaluate", { | |
| 1663 | expression, | |
| 1664 | awaitPromise, | |
| 1665 | returnByValue, | |
| 1666 | userGesture: true, | |
| 1667 | objectGroup, | |
| 1668 | }).pipe( | |
| 1669 | Effect.flatMap((rawResponse) => { | |
| 1670 | const response = rawResponse as CdpEvaluationResult; | |
| 1671 | if (!response.exceptionDetails) { | |
| 1672 | return Effect.succeed(response.result?.value as A); | |
| 1673 | } | |
| 1674 | const detail = previewAutomationEvaluationDetail(response.exceptionDetails); | |
| 1675 | return Effect.fail( | |
| 1676 | new PreviewAutomationEvaluationError({ | |
| 1677 | tabId, | |
| 1678 | detailKind: detail.detailKind, | |
| 1679 | detailLength: detail.detail?.length ?? 0, | |
| 1680 | cause: response.exceptionDetails, | |
| 1681 | }), | |
| 1682 | ); | |
| 1683 | }), | |
| 1684 | ); | |
| 1685 | }); | |
| 1636 | 1686 | |
| 1637 | 1687 | const automationLocator = (input: { |
| 1638 | 1688 | readonly selector?: string | undefined; |
| ... | ... | @@ -2281,6 +2331,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2281 | 2331 | ) { |
| 2282 | 2332 | return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId }); |
| 2283 | 2333 | } |
| 2334 | yield* rendererHistory.register(wc, { surface: "preview", tabId }); | |
| 2284 | 2335 | const attached = yield* Ref.get(attachedRef); |
| 2285 | 2336 | const annotationTheme = yield* Ref.get(annotationThemeRef); |
| 2286 | 2337 | const currentAttachment = attached.get(webContentsId); |
| ... | ... | @@ -2435,6 +2486,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2435 | 2486 | const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* ( |
| 2436 | 2487 | wc: Electron.WebContents, |
| 2437 | 2488 | ) { |
| 2489 | yield* rendererHistory.register(wc, { surface: "preview" }); | |
| 2438 | 2490 | const webContentsId = wc.id; |
| 2439 | 2491 | // A guest destroyed before any tab claims it has no other cleanup path. |
| 2440 | 2492 | wc.once("destroyed", () => { |
| ... | ... | @@ -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 | 3373 | const initializationScope = yield* Scope.fork(parentScope, "sequential"); |
| 3318 | 3374 | const ready = yield* Deferred.make<void, PreviewManagerError>(); |
| 3319 | 3375 | const session: PictureInPictureSession = { |
apps/desktop/src/telemetry/DesktopRendererHistory.ts created+283| ... | ... | @@ -0,0 +1,283 @@ |
| 1 | import type * as Electron from "electron"; | |
| 2 | import * as Cause from "effect/Cause"; | |
| 3 | import * as Clock from "effect/Clock"; | |
| 4 | import * as Context from "effect/Context"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Fiber from "effect/Fiber"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Path from "effect/Path"; | |
| 9 | import * as Queue from "effect/Queue"; | |
| 10 | ||
| 11 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 12 | import { makeRotatingLogFileWriter } from "../app/DesktopObservability.ts"; | |
| 13 | ||
| 14 | export interface RendererIdentity { | |
| 15 | readonly surface: "main" | "preview" | "splash" | "picture-in-picture" | "devtools"; | |
| 16 | readonly tabId?: string; | |
| 17 | } | |
| 18 | ||
| 19 | export class DesktopRendererHistory extends Context.Service< | |
| 20 | DesktopRendererHistory, | |
| 21 | { | |
| 22 | readonly register: ( | |
| 23 | webContents: Electron.WebContents, | |
| 24 | identity: RendererIdentity, | |
| 25 | ) => Effect.Effect<void>; | |
| 26 | readonly recordMetrics: (metrics: ReadonlyArray<Electron.ProcessMetric>) => Effect.Effect<void>; | |
| 27 | readonly shutdown: Effect.Effect<void>; | |
| 28 | } | |
| 29 | >()("@t3tools/desktop/telemetry/DesktopRendererHistory") {} | |
| 30 | ||
| 31 | interface RendererMemory { | |
| 32 | readonly sampledAtUnixMs: number; | |
| 33 | // Electron reports process-wide working sets in KiB, not JavaScript heaps. | |
| 34 | readonly kind: "electron-process-working-set"; | |
| 35 | readonly workingSetBytes: number; | |
| 36 | readonly peakWorkingSetBytes: number; | |
| 37 | } | |
| 38 | ||
| 39 | interface RendererRecord { | |
| 40 | readonly version: 1; | |
| 41 | readonly mainPid: number; | |
| 42 | readonly mainSessionStartedAtUnixMs: number; | |
| 43 | readonly timestampUnixMs: number; | |
| 44 | readonly event: | |
| 45 | | "created" | |
| 46 | | "identified" | |
| 47 | | "dom-ready" | |
| 48 | | "sample" | |
| 49 | | "render-process-gone" | |
| 50 | | "destroyed"; | |
| 51 | readonly webContentsId: number; | |
| 52 | readonly surface: RendererIdentity["surface"]; | |
| 53 | readonly tabId?: string; | |
| 54 | readonly rendererPid: number | null; | |
| 55 | readonly rendererPidSource: "current" | "last-known" | "unavailable"; | |
| 56 | readonly rendererCreationTimeMs: number | null; | |
| 57 | readonly memory: RendererMemory | null; | |
| 58 | readonly reason?: Electron.RenderProcessGoneDetails["reason"]; | |
| 59 | readonly exitCode?: number; | |
| 60 | } | |
| 61 | ||
| 62 | interface TrackedRenderer { | |
| 63 | readonly webContents: Electron.WebContents; | |
| 64 | identity: RendererIdentity; | |
| 65 | pid: number | null; | |
| 66 | creationTimeMs: number | null; | |
| 67 | memory: RendererMemory | null; | |
| 68 | gone: boolean; | |
| 69 | readonly removeListeners: () => void; | |
| 70 | } | |
| 71 | ||
| 72 | const nonnegative = (value: number): number | null => | |
| 73 | Number.isFinite(value) && value >= 0 ? Math.round(value) : null; | |
| 74 | ||
| 75 | /** Small restart-surviving PID history; never reads URLs or page content. */ | |
| 76 | const make = Effect.fn("DesktopRendererHistory.make")(function* () { | |
| 77 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 78 | const path = yield* Path.Path; | |
| 79 | const clock = yield* Clock.Clock; | |
| 80 | const context = yield* Effect.context<never>(); | |
| 81 | const runFork = Effect.runForkWith(context); | |
| 82 | const mainSessionStartedAtUnixMs = clock.currentTimeMillisUnsafe(); | |
| 83 | const records = yield* Queue.sliding<RendererRecord>(256); | |
| 84 | const active = new Map<number, TrackedRenderer>(); | |
| 85 | let accepting = true; | |
| 86 | const writer = yield* makeRotatingLogFileWriter({ | |
| 87 | filePath: path.join(environment.logDir, "renderer-history.ndjson"), | |
| 88 | maxBytes: 256 * 1024, | |
| 89 | maxFiles: 2, | |
| 90 | }).pipe( | |
| 91 | Effect.catch(() => | |
| 92 | Effect.logWarning("Renderer incident history could not be opened.").pipe( | |
| 93 | Effect.as({ writeText: (_chunk: string) => Effect.void }), | |
| 94 | ), | |
| 95 | ), | |
| 96 | ); | |
| 97 | ||
| 98 | const writeRecord = (record: RendererRecord) => | |
| 99 | writer.writeText(`${JSON.stringify(record)}\n`).pipe(Effect.ignore); | |
| 100 | const writerFiber = yield* Effect.forever( | |
| 101 | Effect.uninterruptibleMask((restore) => | |
| 102 | restore(Queue.take(records)).pipe(Effect.flatMap(writeRecord)), | |
| 103 | ).pipe( | |
| 104 | Effect.catchCauseIf( | |
| 105 | (cause) => !Cause.hasInterrupts(cause), | |
| 106 | () => Effect.void, | |
| 107 | ), | |
| 108 | ), | |
| 109 | ).pipe( | |
| 110 | Effect.ensuring( | |
| 111 | Effect.gen(function* () { | |
| 112 | while (Queue.sizeUnsafe(records) > 0) { | |
| 113 | const pending = yield* Queue.takeAll(records); | |
| 114 | yield* Effect.forEach(pending, writeRecord, { discard: true }); | |
| 115 | } | |
| 116 | }), | |
| 117 | ), | |
| 118 | Effect.forkScoped, | |
| 119 | ); | |
| 120 | ||
| 121 | const readPid = (renderer: TrackedRenderer): RendererRecord["rendererPidSource"] => { | |
| 122 | try { | |
| 123 | if (!renderer.gone && !renderer.webContents.isDestroyed()) { | |
| 124 | const pid = renderer.webContents.getOSProcessId(); | |
| 125 | if (Number.isInteger(pid) && pid > 0) { | |
| 126 | if (pid !== renderer.pid) { | |
| 127 | renderer.pid = pid; | |
| 128 | renderer.creationTimeMs = null; | |
| 129 | renderer.memory = null; | |
| 130 | } | |
| 131 | return "current"; | |
| 132 | } | |
| 133 | } | |
| 134 | } catch { | |
| 135 | // Electron can remove a guest before its destruction callback runs. | |
| 136 | } | |
| 137 | return renderer.pid === null ? "unavailable" : "last-known"; | |
| 138 | }; | |
| 139 | const record = ( | |
| 140 | renderer: TrackedRenderer, | |
| 141 | event: RendererRecord["event"], | |
| 142 | details?: Electron.RenderProcessGoneDetails, | |
| 143 | ): void => { | |
| 144 | const rendererPidSource = readPid(renderer); | |
| 145 | Queue.offerUnsafe(records, { | |
| 146 | version: 1, | |
| 147 | mainPid: process.pid, | |
| 148 | mainSessionStartedAtUnixMs, | |
| 149 | timestampUnixMs: clock.currentTimeMillisUnsafe(), | |
| 150 | event, | |
| 151 | webContentsId: renderer.webContents.id, | |
| 152 | ...renderer.identity, | |
| 153 | rendererPid: renderer.pid, | |
| 154 | rendererPidSource, | |
| 155 | rendererCreationTimeMs: renderer.creationTimeMs, | |
| 156 | memory: renderer.memory, | |
| 157 | ...(details === undefined | |
| 158 | ? {} | |
| 159 | : { | |
| 160 | reason: details.reason, | |
| 161 | ...(Number.isFinite(details.exitCode) | |
| 162 | ? { exitCode: Math.round(details.exitCode) } | |
| 163 | : {}), | |
| 164 | }), | |
| 165 | }); | |
| 166 | }; | |
| 167 | ||
| 168 | const register = (webContents: Electron.WebContents, identity: RendererIdentity) => | |
| 169 | Effect.sync(() => { | |
| 170 | if (!accepting || webContents.isDestroyed()) return; | |
| 171 | const boundedIdentity: RendererIdentity = { | |
| 172 | surface: identity.surface, | |
| 173 | ...(identity.tabId === undefined ? {} : { tabId: identity.tabId.slice(0, 128) }), | |
| 174 | }; | |
| 175 | const existing = active.get(webContents.id); | |
| 176 | if (existing !== undefined) { | |
| 177 | if ( | |
| 178 | existing.identity.surface !== boundedIdentity.surface || | |
| 179 | existing.identity.tabId !== boundedIdentity.tabId | |
| 180 | ) { | |
| 181 | existing.identity = boundedIdentity; | |
| 182 | record(existing, "identified"); | |
| 183 | } | |
| 184 | return; | |
| 185 | } | |
| 186 | const onDomReady = () => { | |
| 187 | if (renderer.gone) { | |
| 188 | renderer.pid = null; | |
| 189 | renderer.creationTimeMs = null; | |
| 190 | renderer.memory = null; | |
| 191 | renderer.gone = false; | |
| 192 | } | |
| 193 | record(renderer, "dom-ready"); | |
| 194 | }; | |
| 195 | const onDevToolsOpened = () => { | |
| 196 | const tools = webContents.devToolsWebContents; | |
| 197 | if (tools) | |
| 198 | runFork( | |
| 199 | register(tools, { | |
| 200 | surface: "devtools", | |
| 201 | ...(renderer.identity.tabId === undefined ? {} : { tabId: renderer.identity.tabId }), | |
| 202 | }), | |
| 203 | ); | |
| 204 | }; | |
| 205 | const onGone = (_event: Electron.Event, details: Electron.RenderProcessGoneDetails) => { | |
| 206 | readPid(renderer); | |
| 207 | renderer.gone = true; | |
| 208 | record(renderer, "render-process-gone", details); | |
| 209 | }; | |
| 210 | const onDestroyed = () => { | |
| 211 | record(renderer, "destroyed"); | |
| 212 | active.delete(webContents.id); | |
| 213 | renderer.removeListeners(); | |
| 214 | }; | |
| 215 | const renderer: TrackedRenderer = { | |
| 216 | webContents, | |
| 217 | identity: boundedIdentity, | |
| 218 | pid: null, | |
| 219 | creationTimeMs: null, | |
| 220 | memory: null, | |
| 221 | gone: false, | |
| 222 | removeListeners: () => { | |
| 223 | webContents.removeListener("dom-ready", onDomReady); | |
| 224 | webContents.removeListener("devtools-opened", onDevToolsOpened); | |
| 225 | webContents.removeListener("render-process-gone", onGone); | |
| 226 | webContents.removeListener("destroyed", onDestroyed); | |
| 227 | }, | |
| 228 | }; | |
| 229 | active.set(webContents.id, renderer); | |
| 230 | webContents.on("dom-ready", onDomReady); | |
| 231 | webContents.on("devtools-opened", onDevToolsOpened); | |
| 232 | webContents.on("render-process-gone", onGone); | |
| 233 | webContents.once("destroyed", onDestroyed); | |
| 234 | record(renderer, "created"); | |
| 235 | }).pipe( | |
| 236 | Effect.catchCauseIf( | |
| 237 | (cause) => !Cause.hasInterrupts(cause), | |
| 238 | () => Effect.void, | |
| 239 | ), | |
| 240 | ); | |
| 241 | ||
| 242 | let lastSampledAtUnixMs = -Infinity; | |
| 243 | const recordMetrics = (metrics: ReadonlyArray<Electron.ProcessMetric>) => | |
| 244 | Effect.sync(() => { | |
| 245 | if (!accepting) return; | |
| 246 | const sampledAtUnixMs = clock.currentTimeMillisUnsafe(); | |
| 247 | if (sampledAtUnixMs - lastSampledAtUnixMs < 30_000) return; | |
| 248 | lastSampledAtUnixMs = sampledAtUnixMs; | |
| 249 | const byPid = new Map(metrics.map((metric) => [metric.pid, metric])); | |
| 250 | for (const renderer of active.values()) { | |
| 251 | const pidSource = readPid(renderer); | |
| 252 | // A last-known PID may already have been reused by another process. | |
| 253 | const metric = | |
| 254 | pidSource !== "current" || renderer.pid === null ? undefined : byPid.get(renderer.pid); | |
| 255 | if (metric !== undefined) { | |
| 256 | renderer.creationTimeMs = nonnegative(metric.creationTime); | |
| 257 | renderer.memory = { | |
| 258 | sampledAtUnixMs, | |
| 259 | kind: "electron-process-working-set", | |
| 260 | workingSetBytes: nonnegative(metric.memory.workingSetSize * 1024) ?? 0, | |
| 261 | peakWorkingSetBytes: nonnegative(metric.memory.peakWorkingSetSize * 1024) ?? 0, | |
| 262 | }; | |
| 263 | } | |
| 264 | // A process can host multiple contents; these rows must not be summed. | |
| 265 | record(renderer, "sample"); | |
| 266 | } | |
| 267 | }).pipe( | |
| 268 | Effect.catchCauseIf( | |
| 269 | (cause) => !Cause.hasInterrupts(cause), | |
| 270 | () => Effect.void, | |
| 271 | ), | |
| 272 | ); | |
| 273 | const shutdown = Effect.sync(() => { | |
| 274 | accepting = false; | |
| 275 | for (const renderer of active.values()) renderer.removeListeners(); | |
| 276 | active.clear(); | |
| 277 | }).pipe(Effect.andThen(Fiber.interrupt(writerFiber)), Effect.asVoid); | |
| 278 | yield* Effect.addFinalizer(() => shutdown); | |
| 279 | ||
| 280 | return DesktopRendererHistory.of({ register, recordMetrics, shutdown }); | |
| 281 | }); | |
| 282 | ||
| 283 | export const layer = Layer.effect(DesktopRendererHistory, make()); |
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+328-6| ... | ... | @@ -1,10 +1,12 @@ |
| 1 | 1 | import { DesktopHostTelemetryMessage } from "@t3tools/contracts"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | 3 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | 4 | import * as Deferred from "effect/Deferred"; |
| 4 | 5 | import * as Duration from "effect/Duration"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| 6 | 7 | import * as Exit from "effect/Exit"; |
| 7 | 8 | import * as Fiber from "effect/Fiber"; |
| 9 | import * as FileSystem from "effect/FileSystem"; | |
| 8 | 10 | import * as Layer from "effect/Layer"; |
| 9 | 11 | import * as Option from "effect/Option"; |
| 10 | 12 | import * as Ref from "effect/Ref"; |
| ... | ... | @@ -14,10 +16,19 @@ import * as Stream from "effect/Stream"; |
| 14 | 16 | import * as TestClock from "effect/testing/TestClock"; |
| 15 | 17 | |
| 16 | 18 | import type * as Electron from "electron"; |
| 19 | import * as NodeEvents from "node:events"; | |
| 17 | 20 | |
| 21 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 18 | 22 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 19 | 23 | import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; |
| 20 | 24 | import * as DesktopTelemetryPublisher from "./DesktopTelemetryPublisher.ts"; |
| 25 | import * as DesktopRendererHistory from "./DesktopRendererHistory.ts"; | |
| 26 | ||
| 27 | const historyLayer = Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { | |
| 28 | register: () => Effect.void, | |
| 29 | recordMetrics: () => Effect.void, | |
| 30 | shutdown: Effect.void, | |
| 31 | }); | |
| 21 | 32 | |
| 22 | 33 | function makeElectronAppLayer( |
| 23 | 34 | metrics: ReadonlyArray<Electron.ProcessMetric>, |
| ... | ... | @@ -70,7 +81,7 @@ describe("DesktopTelemetryPublisher", () => { |
| 70 | 81 | }), |
| 71 | 82 | ); |
| 72 | 83 | const layer = DesktopTelemetryPublisher.layer.pipe( |
| 73 | Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer)), | |
| 84 | Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer, historyLayer)), | |
| 74 | 85 | ); |
| 75 | 86 | const scope = yield* Scope.make(); |
| 76 | 87 | |
| ... | ... | @@ -91,6 +102,7 @@ describe("DesktopTelemetryPublisher", () => { |
| 91 | 102 | const systemIdleState = yield* Ref.make<ElectronPowerMonitor.ElectronIdleState>("active"); |
| 92 | 103 | let beforeSystemIdleState: Effect.Effect<void> = Effect.void; |
| 93 | 104 | let metricsReadCount = 0; |
| 105 | const recordedMetrics: ReadonlyArray<Electron.ProcessMetric>[] = []; | |
| 94 | 106 | const simpleListeners = new Map<string, () => void>(); |
| 95 | 107 | let thermalListener: ((state: ElectronPowerMonitor.ElectronThermalState) => void) | null = |
| 96 | 108 | null; |
| ... | ... | @@ -141,6 +153,14 @@ describe("DesktopTelemetryPublisher", () => { |
| 141 | 153 | metricsReadCount += 1; |
| 142 | 154 | }), |
| 143 | 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 | 187 | } |
| 168 | 188 | assert.deepEqual(initialSnapshot.electronProcesses, []); |
| 169 | 189 | assert.equal(initialSnapshot.electronPid, process.pid); |
| 170 | assert.equal(metricsReadCount, 0); | |
| 190 | assert.equal(metricsReadCount, 1); | |
| 191 | assert.deepEqual(recordedMetrics, [metrics]); | |
| 171 | 192 | |
| 172 | 193 | const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild); |
| 173 | 194 | yield* Effect.yieldNow; |
| ... | ... | @@ -181,7 +202,8 @@ describe("DesktopTelemetryPublisher", () => { |
| 181 | 202 | assert.equal(demandedSnapshot.electronProcesses[0]?.creationTimeMs, 1_001); |
| 182 | 203 | assert.equal(demandedSnapshot.electronProcesses[0]?.cpuPercent, 12.5); |
| 183 | 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 | 207 | yield* publisher.handleControlForSource("secondary-backend", { |
| 186 | 208 | version: 1, |
| 187 | 209 | type: "setDiagnosticsDemand", |
| ... | ... | @@ -322,13 +344,13 @@ describe("DesktopTelemetryPublisher", () => { |
| 322 | 344 | (yield* publisher.latest).pipe(Option.getOrThrow).sequence, |
| 323 | 345 | configuredSequence, |
| 324 | 346 | ); |
| 325 | assert.equal(metricsReadCount, metricsAfterStopping); | |
| 347 | assert.equal(metricsReadCount, metricsAfterStopping + 1); | |
| 326 | 348 | yield* TestClock.adjust(Duration.millis(1)); |
| 327 | 349 | assert.equal( |
| 328 | 350 | (yield* publisher.latest).pipe(Option.getOrThrow).sequence, |
| 329 | 351 | configuredSequence + 1, |
| 330 | 352 | ); |
| 331 | assert.equal(metricsReadCount, metricsAfterStopping); | |
| 353 | assert.equal(metricsReadCount, metricsAfterStopping + 2); | |
| 332 | 354 | |
| 333 | 355 | yield* Ref.set(systemIdleState, "locked"); |
| 334 | 356 | yield* TestClock.adjust(Duration.seconds(7)); |
| ... | ... | @@ -404,7 +426,7 @@ describe("DesktopTelemetryPublisher", () => { |
| 404 | 426 | }), |
| 405 | 427 | ); |
| 406 | 428 | const layer = DesktopTelemetryPublisher.layer.pipe( |
| 407 | Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer)), | |
| 429 | Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer, historyLayer)), | |
| 408 | 430 | ); |
| 409 | 431 | |
| 410 | 432 | yield* Effect.gen(function* () { |
| ... | ... | @@ -466,3 +488,303 @@ describe("DesktopTelemetryPublisher", () => { |
| 466 | 488 | }), |
| 467 | 489 | ); |
| 468 | 490 | }); |
| 491 | ||
| 492 | describe("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 | 23 | |
| 24 | 24 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 25 | 25 | import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; |
| 26 | import * as DesktopRendererHistory from "./DesktopRendererHistory.ts"; | |
| 26 | 27 | |
| 27 | 28 | const LIVE_SAMPLE_INTERVAL = Duration.seconds(1); |
| 28 | 29 | const BATTERY_SAMPLE_INTERVAL = Duration.seconds(5); |
| ... | ... | @@ -148,6 +149,7 @@ function sampleInterval( |
| 148 | 149 | export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { |
| 149 | 150 | const electronApp = yield* ElectronApp.ElectronApp; |
| 150 | 151 | const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor; |
| 152 | const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; | |
| 151 | 153 | yield* electronApp.whenReady; |
| 152 | 154 | |
| 153 | 155 | const initialPowerState: PowerState = { |
| ... | ... | @@ -212,10 +214,13 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { |
| 212 | 214 | powerMonitor.getSystemIdleTime, |
| 213 | 215 | powerMonitor.getSystemIdleState(IDLE_THRESHOLD_SECONDS), |
| 214 | 216 | powerMonitor.isOnBatteryPower, |
| 215 | demand ? electronApp.getAppMetrics : Effect.succeed([]), | |
| 217 | electronApp.getAppMetrics, | |
| 216 | 218 | ], |
| 217 | 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 | 224 | const polledLocked = |
| 220 | 225 | systemIdleState === "unknown" |
| 221 | 226 | ? currentPower.locked |
| ... | ... | @@ -270,7 +275,7 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { |
| 270 | 275 | updatedAt: sampledAt, |
| 271 | 276 | }, |
| 272 | 277 | speedLimitPercent: observedPower.speedLimitPercent, |
| 273 | electronProcesses: metrics.map((metric) => ({ | |
| 278 | electronProcesses: (demand ? metrics : []).map((metric) => ({ | |
| 274 | 279 | pid: metric.pid, |
| 275 | 280 | creationTimeMs: Math.max(0, Math.round(metric.creationTime)), |
| 276 | 281 | type: metric.type, |
apps/desktop/src/window/DesktopWindow.test.ts+11| ... | ... | @@ -42,6 +42,7 @@ import * as DesktopState from "../app/DesktopState.ts"; |
| 42 | 42 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 43 | 43 | import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; |
| 44 | 44 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 45 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; | |
| 45 | 46 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; |
| 46 | 47 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 47 | 48 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| ... | ... | @@ -286,6 +287,11 @@ function makeTestLayer(input: { |
| 286 | 287 | Layer.provide( |
| 287 | 288 | Layer.mergeAll( |
| 288 | 289 | desktopAssetsLayer, |
| 290 | Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { | |
| 291 | register: () => Effect.void, | |
| 292 | recordMetrics: () => Effect.void, | |
| 293 | shutdown: Effect.void, | |
| 294 | }), | |
| 289 | 295 | desktopEnvironmentLayer, |
| 290 | 296 | desktopAppSettingsLayer, |
| 291 | 297 | desktopClientSettingsLayer, |
| ... | ... | @@ -401,6 +407,11 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n |
| 401 | 407 | Layer.provide( |
| 402 | 408 | Layer.mergeAll( |
| 403 | 409 | desktopAssetsLayer, |
| 410 | Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { | |
| 411 | register: () => Effect.void, | |
| 412 | recordMetrics: () => Effect.void, | |
| 413 | shutdown: Effect.void, | |
| 414 | }), | |
| 404 | 415 | desktopEnvironmentLayer, |
| 405 | 416 | DesktopAppSettings.layerTest(), |
| 406 | 417 | desktopClientSettingsLayer, |
apps/desktop/src/window/DesktopWindow.ts+5| ... | ... | @@ -29,6 +29,7 @@ import * as PreviewManager from "../preview/Manager.ts"; |
| 29 | 29 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 30 | 30 | import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; |
| 31 | 31 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 32 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; | |
| 32 | 33 | import { makeQuitShortcutHandler } from "./QuitHold.ts"; |
| 33 | 34 | |
| 34 | 35 | const TITLEBAR_HEIGHT = 40; |
| ... | ... | @@ -84,6 +85,7 @@ type DesktopWindowRuntimeServices = |
| 84 | 85 | | ElectronShell.ElectronShell |
| 85 | 86 | | ElectronTheme.ElectronTheme |
| 86 | 87 | | ElectronWindow.ElectronWindow |
| 88 | | DesktopRendererHistory.DesktopRendererHistory | |
| 87 | 89 | | PreviewManager.PreviewManager; |
| 88 | 90 | |
| 89 | 91 | export type DesktopWindowError = |
| ... | ... | @@ -322,6 +324,7 @@ export const make = Effect.gen(function* () { |
| 322 | 324 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 323 | 325 | const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; |
| 324 | 326 | const electronApp = yield* ElectronApp.ElectronApp; |
| 327 | const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; | |
| 325 | 328 | // Window-side latch for the primary backend's readiness. Set by |
| 326 | 329 | // handleBackendReady (driven by the pool's onReady callback), cleared |
| 327 | 330 | // by handleBackendNotReady (driven by onShutdown). Only consumed by |
| ... | ... | @@ -420,6 +423,7 @@ export const make = Effect.gen(function* () { |
| 420 | 423 | }, |
| 421 | 424 | }); |
| 422 | 425 | |
| 426 | yield* rendererHistory.register(window.webContents, { surface: "main" }); | |
| 423 | 427 | if (environment.platform === "darwin") { |
| 424 | 428 | window.setAutoHideCursor(false); |
| 425 | 429 | } |
| ... | ... | @@ -905,6 +909,7 @@ export const make = Effect.gen(function* () { |
| 905 | 909 | sandbox: true, |
| 906 | 910 | }, |
| 907 | 911 | }); |
| 912 | yield* rendererHistory.register(splash.webContents, { surface: "splash" }); | |
| 908 | 913 | yield* Ref.set(splashWindowRef, Option.some(splash)); |
| 909 | 914 | splash.once("closed", () => { |
| 910 | 915 | void runPromise(Ref.set(splashWindowRef, Option.none())); |