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