| 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,6 +33,7 @@ import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts"; |
| 33 | import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; | 33 | import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; |
| 34 | import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; | 34 | import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; |
| 35 | import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; | 35 | import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; |
| 36 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; | ||
| 36 | 37 | ||
| 37 | const DEFAULT_DESKTOP_BACKEND_PORT = 3773; | 38 | const DEFAULT_DESKTOP_BACKEND_PORT = 3773; |
| 38 | const MAX_TCP_PORT = 65_535; | 39 | const 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 }); |
| 343 | 344 | ||
| 344 | const shutdown = yield* DesktopShutdown.DesktopShutdown; | 345 | const shutdown = yield* DesktopShutdown.DesktopShutdown; |
| 346 | const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; | ||
| 345 | 347 | ||
| 346 | yield* Effect.addFinalizer(() => | 348 | yield* Effect.addFinalizer(() => |
| 347 | // Stop every backend in the pool, not just the primary. The | 349 | // 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 scope | 351 | // cascade, so leaving the WSL instance for its parent scope |
| 350 | // finalizer means it gets hard-killed by the OS instead of | 352 | // 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 | ); |
| 354 | 359 | ||
| 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 | ); |
| 229 | 229 | ||
| 230 | const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { | 230 | export 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"; |
| 62 | import * as DesktopState from "./app/DesktopState.ts"; | 62 | import * as DesktopState from "./app/DesktopState.ts"; |
| 63 | import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts"; | 63 | import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts"; |
| 64 | import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; | 64 | import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; |
| 65 | import * as DesktopRendererHistory from "./telemetry/DesktopRendererHistory.ts"; | ||
| 65 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; | 66 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 66 | import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; | 67 | import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; |
| 67 | import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; | 68 | import * 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)); |
| 151 | 153 | ||
| 152 | const desktopSshLayer = desktopSshEnvironmentLayer.pipe( | 154 | const 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"; |
| 22 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 22 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 23 | 23 | ||
| 24 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 24 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 25 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; | ||
| 25 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | 26 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 26 | import * as BrowserSession from "./BrowserSession.ts"; | 27 | import * as BrowserSession from "./BrowserSession.ts"; |
| 27 | import * as PreviewManager from "./Manager.ts"; | 28 | import * as PreviewManager from "./Manager.ts"; |
| ... | @@ -267,6 +268,13 @@ const fileSystemLayer = FileSystem.layerNoop({ | ... | @@ -267,6 +268,13 @@ const fileSystemLayer = FileSystem.layerNoop({ |
| 267 | }); | 268 | }); |
| 268 | 269 | ||
| 269 | const layer = PreviewManager.layer.pipe( | 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 | 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 | ); |
| 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 | 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"; |
| 58 | import * as Layer from "effect/Layer"; | 58 | import * as Layer from "effect/Layer"; |
| 59 | import * as Option from "effect/Option"; | 59 | import * as Option from "effect/Option"; |
| 60 | import * as Path from "effect/Path"; | 60 | import * as Path from "effect/Path"; |
| 61 | import * as Queue from "effect/Queue"; | ||
| 61 | import * as Ref from "effect/Ref"; | 62 | import * as Ref from "effect/Ref"; |
| 62 | import * as Schema from "effect/Schema"; | 63 | import * as Schema from "effect/Schema"; |
| 63 | import * as Semaphore from "effect/Semaphore"; | 64 | import * 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"; |
| 66 | import * as SynchronizedRef from "effect/SynchronizedRef"; | 67 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 67 | 68 | ||
| 68 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 69 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 70 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; | ||
| 69 | import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; | 71 | import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; |
| 70 | import * as BrowserSession from "./BrowserSession.ts"; | 72 | import * as BrowserSession from "./BrowserSession.ts"; |
| 71 | import { | 73 | import { |
| ... | @@ -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 === undefined | 1569 | 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 | }); | ||
| 1636 | 1686 | ||
| 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 @@ | ||
| 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,10 +1,12 @@ |
| 1 | import { DesktopHostTelemetryMessage } from "@t3tools/contracts"; | 1 | import { DesktopHostTelemetryMessage } from "@t3tools/contracts"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, describe, it } from "@effect/vitest"; | 3 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | import * as Deferred from "effect/Deferred"; | 4 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Duration from "effect/Duration"; | 5 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 6 | import * as Exit from "effect/Exit"; | 7 | import * as Exit from "effect/Exit"; |
| 7 | import * as Fiber from "effect/Fiber"; | 8 | import * as Fiber from "effect/Fiber"; |
| 9 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; | 11 | import * as Option from "effect/Option"; |
| 10 | import * as Ref from "effect/Ref"; | 12 | import * 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"; |
| 14 | import * as TestClock from "effect/testing/TestClock"; | 16 | import * as TestClock from "effect/testing/TestClock"; |
| 15 | 17 | ||
| 16 | import type * as Electron from "electron"; | 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 | import * as ElectronApp from "../electron/ElectronApp.ts"; | 22 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 19 | import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; | 23 | import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; |
| 20 | import * as DesktopTelemetryPublisher from "./DesktopTelemetryPublisher.ts"; | 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 | function makeElectronAppLayer( | 33 | function 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(); |
| 76 | 87 | ||
| ... | @@ -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]); | ||
| 171 | 192 | ||
| 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); |
| 332 | 354 | ||
| 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 | ); |
| 409 | 431 | ||
| 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 | |||
| 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,6 +23,7 @@ import * as Stream from "effect/Stream"; |
| 23 | 23 | ||
| 24 | import * as ElectronApp from "../electron/ElectronApp.ts"; | 24 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 25 | import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; | 25 | import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; |
| 26 | import * as DesktopRendererHistory from "./DesktopRendererHistory.ts"; | ||
| 26 | 27 | ||
| 27 | const LIVE_SAMPLE_INTERVAL = Duration.seconds(1); | 28 | const LIVE_SAMPLE_INTERVAL = Duration.seconds(1); |
| 28 | const BATTERY_SAMPLE_INTERVAL = Duration.seconds(5); | 29 | const BATTERY_SAMPLE_INTERVAL = Duration.seconds(5); |
| ... | @@ -148,6 +149,7 @@ function sampleInterval( | ... | @@ -148,6 +149,7 @@ function sampleInterval( |
| 148 | export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { | 149 | export 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; |
| 152 | 154 | ||
| 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.locked | 226 | ? 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"; |
| 42 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | 42 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 43 | import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; | 43 | import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; |
| 44 | import * as ElectronApp from "../electron/ElectronApp.ts"; | 44 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 45 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; | ||
| 45 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; | 46 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; |
| 46 | import * as ElectronShell from "../electron/ElectronShell.ts"; | 47 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 47 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; | 48 | import * 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"; |
| 29 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | 29 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 30 | import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; | 30 | import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; |
| 31 | import * as ElectronApp from "../electron/ElectronApp.ts"; | 31 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 32 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; | ||
| 32 | import { makeQuitShortcutHandler } from "./QuitHold.ts"; | 33 | import { makeQuitShortcutHandler } from "./QuitHold.ts"; |
| 33 | 34 | ||
| 34 | const TITLEBAR_HEIGHT = 40; | 35 | const TITLEBAR_HEIGHT = 40; |
| ... | @@ -84,6 +85,7 @@ type DesktopWindowRuntimeServices = | ... | @@ -84,6 +85,7 @@ type DesktopWindowRuntimeServices = |
| 84 | | ElectronShell.ElectronShell | 85 | | ElectronShell.ElectronShell |
| 85 | | ElectronTheme.ElectronTheme | 86 | | ElectronTheme.ElectronTheme |
| 86 | | ElectronWindow.ElectronWindow | 87 | | ElectronWindow.ElectronWindow |
| 88 | | DesktopRendererHistory.DesktopRendererHistory | ||
| 87 | | PreviewManager.PreviewManager; | 89 | | PreviewManager.PreviewManager; |
| 88 | 90 | ||
| 89 | export type DesktopWindowError = | 91 | export 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 by | 328 | // Window-side latch for the primary backend's readiness. Set by |
| 326 | // handleBackendReady (driven by the pool's onReady callback), cleared | 329 | // handleBackendReady (driven by the pool's onReady callback), cleared |
| 327 | // by handleBackendNotReady (driven by onShutdown). Only consumed by | 330 | // 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 | }); |
| 422 | 425 | ||
| 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())); |