Replace scheduled process shell probes with a persistent native collector, Electron power/process telemetry, Effect services, diagnostics UI, attribution, and release packaging.
Unified · Split
| @@ -22,6 +22,9 @@ jobs: · 21 unmodified lines | |||
| 22 | cache: true | 22 | cache: true |
| 23 | run-install: true | 23 | run-install: true |
| 24 | 24 | ||
| 25 | - name: Setup Rust | ||
| 26 | uses: dtolnay/rust-toolchain@stable | ||
| 27 | |||
| 25 | - name: Ensure Electron runtime is installed | 28 | - name: Ensure Electron runtime is installed |
| 26 | run: vp run --filter @t3tools/desktop ensure:electron | 29 | run: vp run --filter @t3tools/desktop ensure:electron |
| 27 | 30 | ||
| @@ -31,6 +34,9 @@ jobs: · 3 unmodified lines | |||
| 31 | - name: Typecheck | 34 | - name: Typecheck |
| 32 | run: vpr typecheck | 35 | run: vpr typecheck |
| 33 | 36 | ||
| 37 | - name: Check resource monitor formatting | ||
| 38 | run: cargo fmt --manifest-path native/resource-monitor/Cargo.toml -- --check | ||
| 39 | |||
| 34 | - name: Build desktop pipeline | 40 | - name: Build desktop pipeline |
| 35 | run: vp run build:desktop | 41 | run: vp run build:desktop |
| 36 | 42 | ||
| @@ -54,12 +60,18 @@ jobs: · 17 unmodified lines | |||
| 54 | cache: true | 60 | cache: true |
| 55 | run-install: true | 61 | run-install: true |
| 56 | 62 | ||
| 63 | - name: Setup Rust | ||
| 64 | uses: dtolnay/rust-toolchain@stable | ||
| 65 | |||
| 57 | - name: Ensure Electron runtime is installed | 66 | - name: Ensure Electron runtime is installed |
| 58 | run: vp run --filter @t3tools/desktop ensure:electron | 67 | run: vp run --filter @t3tools/desktop ensure:electron |
| 59 | 68 | ||
| 60 | - name: Test | 69 | - name: Test |
| 61 | run: vp run test | 70 | run: vp run test |
| 62 | 71 | ||
| 72 | - name: Test resource monitor | ||
| 73 | run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml | ||
| 74 | |||
| 63 | test_browser: | 75 | test_browser: |
| 64 | name: Test Browser | 76 | name: Test Browser |
| 65 | runs-on: blacksmith-8vcpu-ubuntu-2404 | 77 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| @@ -272,21 +272,29 @@ jobs: · 271 unmodified lines | |||
| 272 | platform: mac | 272 | platform: mac |
| 273 | target: dmg | 273 | target: dmg |
| 274 | arch: arm64 | 274 | arch: arm64 |
| 275 | rust_target: aarch64-apple-darwin | ||
| 276 | resource_key: darwin-arm64 | ||
| 275 | - label: macOS x64 | 277 | - label: macOS x64 |
| 276 | runner: blacksmith-12vcpu-macos-26 | 278 | runner: blacksmith-12vcpu-macos-26 |
| 277 | platform: mac | 279 | platform: mac |
| 278 | target: dmg | 280 | target: dmg |
| 279 | arch: x64 | 281 | arch: x64 |
| 282 | rust_target: x86_64-apple-darwin | ||
| 283 | resource_key: darwin-x64 | ||
| 280 | - label: Linux x64 | 284 | - label: Linux x64 |
| 281 | runner: blacksmith-32vcpu-ubuntu-2404 | 285 | runner: blacksmith-32vcpu-ubuntu-2404 |
| 282 | platform: linux | 286 | platform: linux |
| 283 | target: AppImage | 287 | target: AppImage |
| 284 | arch: x64 | 288 | arch: x64 |
| 289 | rust_target: x86_64-unknown-linux-gnu | ||
| 290 | resource_key: linux-x64 | ||
| 285 | - label: Windows x64 | 291 | - label: Windows x64 |
| 286 | runner: blacksmith-32vcpu-windows-2025 | 292 | runner: blacksmith-32vcpu-windows-2025 |
| 287 | platform: win | 293 | platform: win |
| 288 | target: nsis | 294 | target: nsis |
| 289 | arch: x64 | 295 | arch: x64 |
| 296 | rust_target: x86_64-pc-windows-msvc | ||
| 297 | resource_key: win32-x64 | ||
| 290 | # - label: Windows arm64 | 298 | # - label: Windows arm64 |
| 291 | # runner: windows-11-arm | 299 | # runner: windows-11-arm |
| 292 | # platform: win | 300 | # platform: win |
| @@ -306,6 +314,11 @@ jobs: · 13 unmodified lines | |||
| 306 | cache: true | 314 | cache: true |
| 307 | run-install: true | 315 | run-install: true |
| 308 | 316 | ||
| 317 | - name: Setup Rust | ||
| 318 | uses: dtolnay/rust-toolchain@stable | ||
| 319 | with: | ||
| 320 | targets: ${{ matrix.rust_target }} | ||
| 321 | |||
| 309 | - name: Download relay client tracing config | 322 | - name: Download relay client tracing config |
| 310 | uses: actions/download-artifact@v8 | 323 | uses: actions/download-artifact@v8 |
| 311 | with: | 324 | with: |
| @@ -518,6 +531,19 @@ jobs: · 206 unmodified lines | |||
| 518 | # done | 531 | # done |
| 519 | # fi | 532 | # fi |
| 520 | 533 | ||
| 534 | - name: Collect resource monitor | ||
| 535 | shell: bash | ||
| 536 | run: | | ||
| 537 | set -euo pipefail | ||
| 538 | binary_name="t3-resource-monitor" | ||
| 539 | if [[ "${{ matrix.platform }}" == "win" ]]; then | ||
| 540 | binary_name="${binary_name}.exe" | ||
| 541 | fi | ||
| 542 | source_path="native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}" | ||
| 543 | target_dir="resource-monitor-publish/${{ matrix.resource_key }}" | ||
| 544 | mkdir -p "$target_dir" | ||
| 545 | cp "$source_path" "$target_dir/$binary_name" | ||
| 546 | |||
| 521 | - name: Upload build artifacts | 547 | - name: Upload build artifacts |
| 522 | uses: actions/upload-artifact@v7 | 548 | uses: actions/upload-artifact@v7 |
| 523 | with: | 549 | with: |
| @@ -525,6 +551,13 @@ jobs: · 1 unmodified line | |||
| 525 | path: release-publish/* | 551 | path: release-publish/* |
| 526 | if-no-files-found: error | 552 | if-no-files-found: error |
| 527 | 553 | ||
| 554 | - name: Upload resource monitor | ||
| 555 | uses: actions/upload-artifact@v7 | ||
| 556 | with: | ||
| 557 | name: resource-monitor-${{ matrix.resource_key }} | ||
| 558 | path: resource-monitor-publish/${{ matrix.resource_key }}/* | ||
| 559 | if-no-files-found: error | ||
| 560 | |||
| 528 | publish_cli: | 561 | publish_cli: |
| 529 | name: Publish CLI to npm | 562 | name: Publish CLI to npm |
| 530 | needs: [preflight, relay_public_config, build] | 563 | needs: [preflight, relay_public_config, build] |
| @@ -579,6 +612,24 @@ jobs: · 48 unmodified lines | |||
| 579 | - name: Build CLI package | 612 | - name: Build CLI package |
| 580 | run: vp run --filter t3 build | 613 | run: vp run --filter t3 build |
| 581 | 614 | ||
| 615 | - name: Download resource monitors | ||
| 616 | uses: actions/download-artifact@v8 | ||
| 617 | with: | ||
| 618 | pattern: resource-monitor-* | ||
| 619 | path: ${{ runner.temp }}/resource-monitors | ||
| 620 | |||
| 621 | - name: Bundle resource monitors into CLI package | ||
| 622 | shell: bash | ||
| 623 | run: | | ||
| 624 | set -euo pipefail | ||
| 625 | for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do | ||
| 626 | resource_key="${artifact_dir##*/resource-monitor-}" | ||
| 627 | target_dir="apps/server/dist/resource-monitor/${resource_key}" | ||
| 628 | mkdir -p "$target_dir" | ||
| 629 | cp "$artifact_dir"/t3-resource-monitor* "$target_dir/" | ||
| 630 | chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true | ||
| 631 | done | ||
| 632 | |||
| 582 | - name: Publish CLI package | 633 | - name: Publish CLI package |
| 583 | run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose | 634 | run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose |
| 584 | 635 | ||
| @@ -27,6 +27,7 @@ squashfs-root/ · 26 unmodified lines | |||
| 27 | .gstack/ | 27 | .gstack/ |
| 28 | dist-electron/ | 28 | dist-electron/ |
| 29 | .electron-runtime/ | 29 | .electron-runtime/ |
| 30 | native/**/target/ | ||
| 30 | node_modules/ | 31 | node_modules/ |
| 31 | .alchemy/ | 32 | .alchemy/ |
| 32 | *.log | 33 | *.log |
| @@ -54,6 +54,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => · 53 unmodified lines | |||
| 54 | }), | 54 | }), |
| 55 | setAppUserModelId: () => Effect.void, | 55 | setAppUserModelId: () => Effect.void, |
| 56 | requestSingleInstanceLock: Effect.succeed(true), | 56 | requestSingleInstanceLock: Effect.succeed(true), |
| 57 | getAppMetrics: Effect.succeed([]), | ||
| 57 | isDefaultProtocolClient: () => Effect.succeed(false), | 58 | isDefaultProtocolClient: () => Effect.succeed(false), |
| 58 | setAsDefaultProtocolClient: () => Effect.succeed(true), | 59 | setAsDefaultProtocolClient: () => Effect.succeed(true), |
| 59 | setDesktopName: () => Effect.void, | 60 | setDesktopName: () => Effect.void, |
| @@ -54,6 +54,7 @@ function makeHarness(input: { readonly isDevelopment: boolean }): CloudAuthHarne · 53 unmodified lines | |||
| 54 | setAboutPanelOptions: () => Effect.void, | 54 | setAboutPanelOptions: () => Effect.void, |
| 55 | setAppUserModelId: () => Effect.void, | 55 | setAppUserModelId: () => Effect.void, |
| 56 | requestSingleInstanceLock: Effect.succeed(true), | 56 | requestSingleInstanceLock: Effect.succeed(true), |
| 57 | getAppMetrics: Effect.succeed([]), | ||
| 57 | isDefaultProtocolClient: () => Effect.succeed(false), | 58 | isDefaultProtocolClient: () => Effect.succeed(false), |
| 58 | setAsDefaultProtocolClient: (protocol, path, args) => | 59 | setAsDefaultProtocolClient: (protocol, path, args) => |
| 59 | Effect.sync(() => { | 60 | Effect.sync(() => { |
| @@ -41,6 +41,7 @@ function makeEnvironmentLayer( · 40 unmodified lines | |||
| 41 | options?: { | 41 | options?: { |
| 42 | readonly isPackaged?: boolean; | 42 | readonly isPackaged?: boolean; |
| 43 | readonly devServerUrl?: string; | 43 | readonly devServerUrl?: string; |
| 44 | readonly resourcesPath?: string; | ||
| 44 | }, | 45 | }, |
| 45 | ) { | 46 | ) { |
| 46 | return DesktopEnvironment.layer({ | 47 | return DesktopEnvironment.layer({ |
| @@ -51,7 +52,7 @@ function makeEnvironmentLayer( · 4 unmodified lines | |||
| 51 | appVersion: "1.2.3", | 52 | appVersion: "1.2.3", |
| 52 | appPath: "/repo", | 53 | appPath: "/repo", |
| 53 | isPackaged: options?.isPackaged ?? true, | 54 | isPackaged: options?.isPackaged ?? true, |
| 54 | resourcesPath: "/missing/resources", | 55 | resourcesPath: options?.resourcesPath ?? "/missing/resources", |
| 55 | runningUnderArm64Translation: false, | 56 | runningUnderArm64Translation: false, |
| 56 | }).pipe( | 57 | }).pipe( |
| 57 | Layer.provide( | 58 | Layer.provide( |
| @@ -192,4 +193,38 @@ describe("DesktopBackendConfiguration", () => { · 134 unmodified lines | |||
| 192 | ); | 193 | ); |
| 193 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 194 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 194 | ); | 195 | ); |
| 196 | |||
| 197 | it.effect("passes the packaged resource monitor path to the backend", () => | ||
| 198 | Effect.gen(function* () { | ||
| 199 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 200 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 201 | prefix: "t3-desktop-backend-config-test-", | ||
| 202 | }); | ||
| 203 | const resourcesPath = `${baseDir}/resources`; | ||
| 204 | const monitorPath = `${resourcesPath}/resource-monitor/t3-resource-monitor`; | ||
| 205 | yield* fileSystem.makeDirectory(`${resourcesPath}/resource-monitor`, { | ||
| 206 | recursive: true, | ||
| 207 | }); | ||
| 208 | yield* fileSystem.writeFileString(monitorPath, "binary"); | ||
| 209 | yield* fileSystem.chmod(monitorPath, 0o755); | ||
| 210 | |||
| 211 | yield* Effect.gen(function* () { | ||
| 212 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | ||
| 213 | const config = yield* configuration.resolve; | ||
| 214 | assert.equal(config.bootstrap.resourceMonitorPath, monitorPath); | ||
| 215 | assert.equal(config.bootstrap.desktopTelemetryFd, 4); | ||
| 216 | }).pipe( | ||
| 217 | Effect.provide( | ||
| 218 | DesktopBackendConfiguration.layer.pipe( | ||
| 219 | Layer.provideMerge(serverExposureLayer), | ||
| 220 | Layer.provideMerge( | ||
| 221 | makeEnvironmentLayer(baseDir, { | ||
| 222 | resourcesPath, | ||
| 223 | }), | ||
| 224 | ), | ||
| 225 | ), | ||
| 226 | ), | ||
| 227 | ); | ||
| 228 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 229 | ); | ||
| 195 | }); | 230 | }); |
| @@ -56,6 +56,42 @@ const { logWarning: logBackendConfigurationWarning } = DesktopObservability.make · 55 unmodified lines | |||
| 56 | "desktop-backend-configuration", | 56 | "desktop-backend-configuration", |
| 57 | ); | 57 | ); |
| 58 | 58 | ||
| 59 | function resourceMonitorBinaryName(platform: NodeJS.Platform): string { | ||
| 60 | return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor"; | ||
| 61 | } | ||
| 62 | |||
| 63 | const resolveResourceMonitorPath = Effect.fn( | ||
| 64 | "desktop.backendConfiguration.resolveResourceMonitorPath", | ||
| 65 | )(function* () { | ||
| 66 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 67 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 68 | const binaryName = resourceMonitorBinaryName(environment.platform); | ||
| 69 | const candidates = environment.isDevelopment | ||
| 70 | ? [ | ||
| 71 | environment.path.join( | ||
| 72 | environment.rootDir, | ||
| 73 | "native/resource-monitor/target/debug", | ||
| 74 | binaryName, | ||
| 75 | ), | ||
| 76 | environment.path.join( | ||
| 77 | environment.rootDir, | ||
| 78 | "native/resource-monitor/target/release", | ||
| 79 | binaryName, | ||
| 80 | ), | ||
| 81 | ] | ||
| 82 | : environment.resolveResourcePathCandidates( | ||
| 83 | environment.path.join("resource-monitor", binaryName), | ||
| 84 | ); | ||
| 85 | |||
| 86 | for (const candidate of candidates) { | ||
| 87 | if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { | ||
| 88 | return Option.some(candidate); | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | return Option.none<string>(); | ||
| 93 | }); | ||
| 94 | |||
| 59 | const readPersistedBackendObservabilitySettings: Effect.Effect< | 95 | const readPersistedBackendObservabilitySettings: Effect.Effect< |
| 60 | BackendObservabilitySettings, | 96 | BackendObservabilitySettings, |
| 61 | never, | 97 | never, |
| @@ -89,6 +125,7 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv · 27 unmodified lines | |||
| 89 | function* (input: { | 125 | function* (input: { |
| 90 | readonly bootstrapToken: string; | 126 | readonly bootstrapToken: string; |
| 91 | readonly observabilitySettings: BackendObservabilitySettings; | 127 | readonly observabilitySettings: BackendObservabilitySettings; |
| 128 | readonly resourceMonitorPath: Option.Option<string>; | ||
| 92 | }): Effect.fn.Return< | 129 | }): Effect.fn.Return< |
| 93 | DesktopBackendManager.DesktopBackendStartConfig, | 130 | DesktopBackendManager.DesktopBackendStartConfig, |
| 94 | never, | 131 | never, |
| @@ -115,6 +152,11 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv · 20 unmodified lines | |||
| 115 | desktopBootstrapToken: input.bootstrapToken, | 152 | desktopBootstrapToken: input.bootstrapToken, |
| 116 | tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, | 153 | tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, |
| 117 | tailscaleServePort: backendExposure.tailscaleServePort, | 154 | tailscaleServePort: backendExposure.tailscaleServePort, |
| 155 | desktopTelemetryFd: 4, | ||
| 156 | ...Option.match(input.resourceMonitorPath, { | ||
| 157 | onNone: () => ({}), | ||
| 158 | onSome: (resourceMonitorPath) => ({ resourceMonitorPath }), | ||
| 159 | }), | ||
| 118 | ...Option.match(input.observabilitySettings.otlpTracesUrl, { | 160 | ...Option.match(input.observabilitySettings.otlpTracesUrl, { |
| 119 | onNone: () => ({}), | 161 | onNone: () => ({}), |
| 120 | onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), | 162 | onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), |
| @@ -156,9 +198,14 @@ export const layer = Layer.effect( · 35 unmodified lines | |||
| 156 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 198 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 157 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | 199 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), |
| 158 | ); | 200 | ); |
| 201 | const resourceMonitorPath = yield* resolveResourceMonitorPath().pipe( | ||
| 202 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 203 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | ||
| 204 | ); | ||
| 159 | return yield* resolveBackendStartConfig({ | 205 | return yield* resolveBackendStartConfig({ |
| 160 | bootstrapToken, | 206 | bootstrapToken, |
| 161 | observabilitySettings, | 207 | observabilitySettings, |
| 208 | resourceMonitorPath, | ||
| 162 | }).pipe( | 209 | }).pipe( |
| 163 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | 210 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), |
| 164 | Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), | 211 | Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), |
| @@ -23,6 +23,7 @@ import * as DesktopBackendManager from "./DesktopBackendManager.ts"; · 22 unmodified lines | |||
| 23 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | 23 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; |
| 24 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 24 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 25 | import * as DesktopState from "../app/DesktopState.ts"; | 25 | import * as DesktopState from "../app/DesktopState.ts"; |
| 26 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | ||
| 26 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | 27 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 27 | 28 | ||
| 28 | const decodeDesktopBackendBootstrap = Schema.decodeEffect( | 29 | const decodeDesktopBackendBootstrap = Schema.decodeEffect( |
| @@ -120,6 +121,11 @@ function makeManagerLayer(input: { · 91 unmodified lines | |||
| 120 | }), | 121 | }), |
| 121 | input.spawnerLayer, | 122 | input.spawnerLayer, |
| 122 | input.httpClientLayer ?? healthyHttpClientLayer, | 123 | input.httpClientLayer ?? healthyHttpClientLayer, |
| 124 | Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, { | ||
| 125 | latest: Effect.succeed(Option.none()), | ||
| 126 | changes: Stream.empty, | ||
| 127 | encoded: Stream.empty, | ||
| 128 | }), | ||
| 123 | input.desktopState | 129 | input.desktopState |
| 124 | ? Layer.succeed(DesktopState.DesktopState, input.desktopState) | 130 | ? Layer.succeed(DesktopState.DesktopState, input.desktopState) |
| 125 | : DesktopState.layer, | 131 | : DesktopState.layer, |
| @@ -27,6 +27,7 @@ import { · 26 unmodified lines | |||
| 27 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | 27 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; |
| 28 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 28 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 29 | import * as DesktopState from "../app/DesktopState.ts"; | 29 | import * as DesktopState from "../app/DesktopState.ts"; |
| 30 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | ||
| 30 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | 31 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 31 | 32 | ||
| 32 | const INITIAL_RESTART_DELAY = Duration.millis(500); | 33 | const INITIAL_RESTART_DELAY = Duration.millis(500); |
| @@ -88,6 +89,7 @@ class BackendProcessSpawnError extends Data.TaggedError("BackendProcessSpawnErro · 55 unmodified lines | |||
| 88 | type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; | 89 | type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; |
| 89 | 90 | ||
| 90 | interface RunBackendProcessOptions extends DesktopBackendStartConfig { | 91 | interface RunBackendProcessOptions extends DesktopBackendStartConfig { |
| 92 | readonly desktopTelemetryStream: Stream.Stream<Uint8Array>; | ||
| 91 | readonly readinessTimeout?: Duration.Duration; | 93 | readonly readinessTimeout?: Duration.Duration; |
| 92 | readonly onStarted?: (pid: number) => Effect.Effect<void>; | 94 | readonly onStarted?: (pid: number) => Effect.Effect<void>; |
| 93 | readonly onReady?: () => Effect.Effect<void>; | 95 | readonly onReady?: () => Effect.Effect<void>; |
| @@ -252,6 +254,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( · 158 unmodified lines | |||
| 252 | type: "input", | 254 | type: "input", |
| 253 | stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)), | 255 | stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)), |
| 254 | }, | 256 | }, |
| 257 | fd4: { | ||
| 258 | type: "input", | ||
| 259 | stream: options.desktopTelemetryStream, | ||
| 260 | }, | ||
| 255 | }, | 261 | }, |
| 256 | }, | 262 | }, |
| 257 | ); | 263 | ); |
| @@ -283,6 +289,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio · 25 unmodified lines | |||
| 283 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | 289 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| 284 | const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog; | 290 | const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog; |
| 285 | const desktopState = yield* DesktopState.DesktopState; | 291 | const desktopState = yield* DesktopState.DesktopState; |
| 292 | const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; | ||
| 286 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | 293 | const desktopWindow = yield* DesktopWindow.DesktopWindow; |
| 287 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 294 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 288 | const httpClient = yield* HttpClient.HttpClient; | 295 | const httpClient = yield* HttpClient.HttpClient; |
| @@ -436,6 +443,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio · 147 unmodified lines | |||
| 436 | 443 | ||
| 437 | const program = runBackendProcess({ | 444 | const program = runBackendProcess({ |
| 438 | ...config.value, | 445 | ...config.value, |
| 446 | desktopTelemetryStream: desktopTelemetryPublisher.encoded, | ||
| 439 | onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) { | 447 | onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) { |
| 440 | yield* updateActiveRun(runId, (run) => ({ | 448 | yield* updateActiveRun(runId, (run) => ({ |
| 441 | ...run, | 449 | ...run, |
| @@ -30,6 +30,7 @@ export interface ElectronAppShape { · 29 unmodified lines | |||
| 30 | ) => Effect.Effect<void>; | 30 | ) => Effect.Effect<void>; |
| 31 | readonly setAppUserModelId: (id: string) => Effect.Effect<void>; | 31 | readonly setAppUserModelId: (id: string) => Effect.Effect<void>; |
| 32 | readonly requestSingleInstanceLock: Effect.Effect<boolean>; | 32 | readonly requestSingleInstanceLock: Effect.Effect<boolean>; |
| 33 | readonly getAppMetrics: Effect.Effect<ReadonlyArray<Electron.ProcessMetric>>; | ||
| 33 | readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>; | 34 | readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>; |
| 34 | readonly setAsDefaultProtocolClient: ( | 35 | readonly setAsDefaultProtocolClient: ( |
| 35 | protocol: string, | 36 | protocol: string, |
| @@ -101,6 +102,7 @@ const make = ElectronApp.of({ · 65 unmodified lines | |||
| 101 | Electron.app.setAppUserModelId(id); | 102 | Electron.app.setAppUserModelId(id); |
| 102 | }), | 103 | }), |
| 103 | requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), | 104 | requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), |
| 105 | getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), | ||
| 104 | isDefaultProtocolClient: (protocol) => | 106 | isDefaultProtocolClient: (protocol) => |
| 105 | Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), | 107 | Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), |
| 106 | setAsDefaultProtocolClient: (protocol, path, args) => | 108 | setAsDefaultProtocolClient: (protocol, path, args) => |
| @@ -0,0 +1,89 @@ | |||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Scope from "effect/Scope"; | ||
| 5 | |||
| 6 | import * as Electron from "electron"; | ||
| 7 | |||
| 8 | export type ElectronThermalState = ReturnType<Electron.PowerMonitor["getCurrentThermalState"]>; | ||
| 9 | export type ElectronIdleState = ReturnType<Electron.PowerMonitor["getSystemIdleState"]>; | ||
| 10 | |||
| 11 | export interface ElectronPowerMonitorShape { | ||
| 12 | readonly isOnBatteryPower: Effect.Effect<boolean>; | ||
| 13 | readonly getSystemIdleTime: Effect.Effect<number>; | ||
| 14 | readonly getSystemIdleState: (idleThresholdSeconds: number) => Effect.Effect<ElectronIdleState>; | ||
| 15 | readonly getCurrentThermalState: Effect.Effect<ElectronThermalState>; | ||
| 16 | readonly onSimpleEvent: ( | ||
| 17 | eventName: "lock-screen" | "unlock-screen" | "on-ac" | "on-battery" | "suspend" | "resume", | ||
| 18 | listener: () => void, | ||
| 19 | ) => Effect.Effect<void, never, Scope.Scope>; | ||
| 20 | readonly onThermalStateChange: ( | ||
| 21 | listener: (state: ElectronThermalState) => void, | ||
| 22 | ) => Effect.Effect<void, never, Scope.Scope>; | ||
| 23 | readonly onSpeedLimitChange: ( | ||
| 24 | listener: (limit: number) => void, | ||
| 25 | ) => Effect.Effect<void, never, Scope.Scope>; | ||
| 26 | } | ||
| 27 | |||
| 28 | export class ElectronPowerMonitor extends Context.Service< | ||
| 29 | ElectronPowerMonitor, | ||
| 30 | ElectronPowerMonitorShape | ||
| 31 | >()("@t3tools/desktop/electron/ElectronPowerMonitor") {} | ||
| 32 | |||
| 33 | const onSimpleEvent: ElectronPowerMonitorShape["onSimpleEvent"] = (eventName, listener) => | ||
| 34 | Effect.acquireRelease( | ||
| 35 | Effect.sync(() => { | ||
| 36 | Electron.powerMonitor.on(eventName as any, listener as any); | ||
| 37 | }), | ||
| 38 | () => | ||
| 39 | Effect.sync(() => { | ||
| 40 | Electron.powerMonitor.removeListener(eventName as any, listener as any); | ||
| 41 | }), | ||
| 42 | ).pipe(Effect.asVoid); | ||
| 43 | |||
| 44 | const onThermalStateChange: ElectronPowerMonitorShape["onThermalStateChange"] = (listener) => { | ||
| 45 | const wrapped = ( | ||
| 46 | event: Electron.Event<Electron.PowerMonitorThermalStateChangeEventParams>, | ||
| 47 | ): void => { | ||
| 48 | listener(event.state); | ||
| 49 | }; | ||
| 50 | return Effect.acquireRelease( | ||
| 51 | Effect.sync(() => { | ||
| 52 | Electron.powerMonitor.on("thermal-state-change", wrapped); | ||
| 53 | }), | ||
| 54 | () => | ||
| 55 | Effect.sync(() => { | ||
| 56 | Electron.powerMonitor.removeListener("thermal-state-change", wrapped); | ||
| 57 | }), | ||
| 58 | ).pipe(Effect.asVoid); | ||
| 59 | }; | ||
| 60 | |||
| 61 | const onSpeedLimitChange: ElectronPowerMonitorShape["onSpeedLimitChange"] = (listener) => { | ||
| 62 | const wrapped = ( | ||
| 63 | event: Electron.Event<Electron.PowerMonitorSpeedLimitChangeEventParams>, | ||
| 64 | ): void => { | ||
| 65 | listener(event.limit); | ||
| 66 | }; | ||
| 67 | return Effect.acquireRelease( | ||
| 68 | Effect.sync(() => { | ||
| 69 | Electron.powerMonitor.on("speed-limit-change", wrapped); | ||
| 70 | }), | ||
| 71 | () => | ||
| 72 | Effect.sync(() => { | ||
| 73 | Electron.powerMonitor.removeListener("speed-limit-change", wrapped); | ||
| 74 | }), | ||
| 75 | ).pipe(Effect.asVoid); | ||
| 76 | }; | ||
| 77 | |||
| 78 | export const make = ElectronPowerMonitor.of({ | ||
| 79 | isOnBatteryPower: Effect.sync(() => Electron.powerMonitor.isOnBatteryPower()), | ||
| 80 | getSystemIdleTime: Effect.sync(() => Electron.powerMonitor.getSystemIdleTime()), | ||
| 81 | getSystemIdleState: (idleThresholdSeconds) => | ||
| 82 | Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)), | ||
| 83 | getCurrentThermalState: Effect.sync(() => Electron.powerMonitor.getCurrentThermalState()), | ||
| 84 | onSimpleEvent, | ||
| 85 | onThermalStateChange, | ||
| 86 | onSpeedLimitChange, | ||
| 87 | }); | ||
| 88 | |||
| 89 | export const layer = Layer.succeed(ElectronPowerMonitor, make); | ||
| @@ -19,6 +19,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts"; · 18 unmodified lines | |||
| 19 | import * as ElectronApp from "./electron/ElectronApp.ts"; | 19 | import * as ElectronApp from "./electron/ElectronApp.ts"; |
| 20 | import * as ElectronDialog from "./electron/ElectronDialog.ts"; | 20 | import * as ElectronDialog from "./electron/ElectronDialog.ts"; |
| 21 | import * as ElectronMenu from "./electron/ElectronMenu.ts"; | 21 | import * as ElectronMenu from "./electron/ElectronMenu.ts"; |
| 22 | import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts"; | ||
| 22 | import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; | 23 | import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; |
| 23 | import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; | 24 | import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; |
| 24 | import * as ElectronShell from "./electron/ElectronShell.ts"; | 25 | import * as ElectronShell from "./electron/ElectronShell.ts"; |
| @@ -44,6 +45,7 @@ import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; · 19 unmodified lines | |||
| 44 | import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; | 45 | import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; |
| 45 | import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; | 46 | import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; |
| 46 | import * as DesktopState from "./app/DesktopState.ts"; | 47 | import * as DesktopState from "./app/DesktopState.ts"; |
| 48 | import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; | ||
| 47 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; | 49 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 48 | import * as PreviewBrowserSession from "./preview/BrowserSession.ts"; | 50 | import * as PreviewBrowserSession from "./preview/BrowserSession.ts"; |
| 49 | import * as PreviewManager from "./preview/Manager.ts"; | 51 | import * as PreviewManager from "./preview/Manager.ts"; |
| @@ -103,6 +105,7 @@ const electronLayer = Layer.mergeAll( · 53 unmodified lines | |||
| 103 | ElectronApp.layer, | 105 | ElectronApp.layer, |
| 104 | ElectronDialog.layer, | 106 | ElectronDialog.layer, |
| 105 | ElectronMenu.layer, | 107 | ElectronMenu.layer, |
| 108 | ElectronPowerMonitor.layer, | ||
| 106 | ElectronProtocol.layer, | 109 | ElectronProtocol.layer, |
| 107 | DesktopSecretStorage.layer, | 110 | DesktopSecretStorage.layer, |
| 108 | ElectronShell.layer, | 111 | ElectronShell.layer, |
| @@ -145,6 +148,7 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( · 36 unmodified lines | |||
| 145 | const desktopBackendLayer = DesktopBackendManager.layer.pipe( | 148 | const desktopBackendLayer = DesktopBackendManager.layer.pipe( |
| 146 | Layer.provideMerge(DesktopAppIdentity.layer), | 149 | Layer.provideMerge(DesktopAppIdentity.layer), |
| 147 | Layer.provideMerge(DesktopBackendConfiguration.layer), | 150 | Layer.provideMerge(DesktopBackendConfiguration.layer), |
| 151 | Layer.provideMerge(DesktopTelemetryPublisher.layer), | ||
| 148 | Layer.provideMerge(desktopWindowLayer), | 152 | Layer.provideMerge(desktopWindowLayer), |
| 149 | ); | 153 | ); |
| 150 | 154 | ||
| @@ -0,0 +1,122 @@ | |||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Ref from "effect/Ref"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import * as TestClock from "effect/testing/TestClock"; | ||
| 10 | |||
| 11 | import type * as Electron from "electron"; | ||
| 12 | |||
| 13 | import * as ElectronApp from "../electron/ElectronApp.ts"; | ||
| 14 | import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; | ||
| 15 | import * as DesktopTelemetryPublisher from "./DesktopTelemetryPublisher.ts"; | ||
| 16 | |||
| 17 | function makeElectronAppLayer(metrics: ReadonlyArray<Electron.ProcessMetric>) { | ||
| 18 | return Layer.succeed(ElectronApp.ElectronApp, { | ||
| 19 | metadata: Effect.die("unexpected metadata read"), | ||
| 20 | name: Effect.succeed("T3 Code"), | ||
| 21 | whenReady: Effect.void, | ||
| 22 | quit: Effect.void, | ||
| 23 | exit: () => Effect.void, | ||
| 24 | relaunch: () => Effect.void, | ||
| 25 | setPath: () => Effect.void, | ||
| 26 | setName: () => Effect.void, | ||
| 27 | setAboutPanelOptions: () => Effect.void, | ||
| 28 | setAppUserModelId: () => Effect.void, | ||
| 29 | requestSingleInstanceLock: Effect.succeed(true), | ||
| 30 | getAppMetrics: Effect.succeed(metrics), | ||
| 31 | isDefaultProtocolClient: () => Effect.succeed(false), | ||
| 32 | setAsDefaultProtocolClient: () => Effect.succeed(true), | ||
| 33 | setDesktopName: () => Effect.void, | ||
| 34 | setDockIcon: () => Effect.void, | ||
| 35 | appendCommandLineSwitch: () => Effect.void, | ||
| 36 | on: () => Effect.void, | ||
| 37 | } satisfies ElectronApp.ElectronAppShape); | ||
| 38 | } | ||
| 39 | |||
| 40 | describe("DesktopTelemetryPublisher", () => { | ||
| 41 | it.effect("publishes Electron metrics and event-driven power state over NDJSON", () => | ||
| 42 | Effect.gen(function* () { | ||
| 43 | const onBattery = yield* Ref.make(false); | ||
| 44 | const simpleListeners = new Map<string, () => void>(); | ||
| 45 | let thermalListener: ((state: ElectronPowerMonitor.ElectronThermalState) => void) | null = | ||
| 46 | null; | ||
| 47 | let speedLimitListener: ((limit: number) => void) | null = null; | ||
| 48 | const metrics = [ | ||
| 49 | { | ||
| 50 | pid: 4_242, | ||
| 51 | type: "Browser", | ||
| 52 | creationTime: 1_000, | ||
| 53 | name: "electron", | ||
| 54 | cpu: { | ||
| 55 | percentCPUUsage: 12.5, | ||
| 56 | cumulativeCPUUsage: 3.25, | ||
| 57 | idleWakeupsPerSecond: 7, | ||
| 58 | }, | ||
| 59 | memory: { | ||
| 60 | workingSetSize: 2_048, | ||
| 61 | peakWorkingSetSize: 4_096, | ||
| 62 | }, | ||
| 63 | } as Electron.ProcessMetric, | ||
| 64 | ]; | ||
| 65 | const powerLayer = Layer.succeed( | ||
| 66 | ElectronPowerMonitor.ElectronPowerMonitor, | ||
| 67 | ElectronPowerMonitor.ElectronPowerMonitor.of({ | ||
| 68 | isOnBatteryPower: Ref.get(onBattery), | ||
| 69 | getSystemIdleTime: Effect.succeed(5), | ||
| 70 | getSystemIdleState: () => Effect.succeed("active"), | ||
| 71 | getCurrentThermalState: Effect.succeed("nominal"), | ||
| 72 | onSimpleEvent: (eventName, listener) => | ||
| 73 | Effect.sync(() => { | ||
| 74 | simpleListeners.set(eventName, listener); | ||
| 75 | }), | ||
| 76 | onThermalStateChange: (listener) => | ||
| 77 | Effect.sync(() => { | ||
| 78 | thermalListener = listener; | ||
| 79 | }), | ||
| 80 | onSpeedLimitChange: (listener) => | ||
| 81 | Effect.sync(() => { | ||
| 82 | speedLimitListener = listener; | ||
| 83 | }), | ||
| 84 | }), | ||
| 85 | ); | ||
| 86 | const layer = DesktopTelemetryPublisher.layer.pipe( | ||
| 87 | Layer.provide(Layer.mergeAll(makeElectronAppLayer(metrics), powerLayer)), | ||
| 88 | ); | ||
| 89 | |||
| 90 | yield* Effect.gen(function* () { | ||
| 91 | const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; | ||
| 92 | const encoded = yield* publisher.encoded.pipe(Stream.take(2), Stream.runCollect); | ||
| 93 | const decoder = new TextDecoder(); | ||
| 94 | const messages = Array.from(encoded, (bytes) => JSON.parse(decoder.decode(bytes).trim())); | ||
| 95 | |||
| 96 | assert.equal(messages[0]?.type, "desktopTelemetryHello"); | ||
| 97 | assert.equal(messages[0]?.electronPid, process.pid); | ||
| 98 | assert.equal(messages[1]?.type, "desktopTelemetry"); | ||
| 99 | assert.equal(messages[1]?.electronProcesses[0]?.pid, 4_242); | ||
| 100 | assert.equal(messages[1]?.electronProcesses[0]?.cpuPercent, 12.5); | ||
| 101 | assert.equal(messages[1]?.electronProcesses[0]?.workingSetBytes, 2_048 * 1_024); | ||
| 102 | |||
| 103 | const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild); | ||
| 104 | yield* Effect.yieldNow; | ||
| 105 | yield* Ref.set(onBattery, true); | ||
| 106 | simpleListeners.get("lock-screen")?.(); | ||
| 107 | simpleListeners.get("suspend")?.(); | ||
| 108 | thermalListener?.("serious"); | ||
| 109 | speedLimitListener?.(65); | ||
| 110 | yield* Effect.yieldNow; | ||
| 111 | yield* TestClock.adjust(Duration.seconds(1)); | ||
| 112 | |||
| 113 | const nextSnapshot = Option.getOrThrow(yield* Fiber.join(nextSnapshotFiber)); | ||
| 114 | assert.equal(nextSnapshot.power.locked, "true"); | ||
| 115 | assert.equal(nextSnapshot.power.suspended, true); | ||
| 116 | assert.equal(nextSnapshot.power.onBattery, "true"); | ||
| 117 | assert.equal(nextSnapshot.power.thermalState, "serious"); | ||
| 118 | assert.equal(Option.getOrNull(nextSnapshot.speedLimitPercent), 65); | ||
| 119 | }).pipe(Effect.provide(layer)); | ||
| 120 | }), | ||
| 121 | ); | ||
| 122 | }); | ||
| @@ -0,0 +1,215 @@ | |||
| 1 | import { | ||
| 2 | DesktopHostTelemetryMessage, | ||
| 3 | type DesktopHostTelemetrySnapshot, | ||
| 4 | type HostPowerSnapshot, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import * as Context from "effect/Context"; | ||
| 7 | import * as DateTime from "effect/DateTime"; | ||
| 8 | import * as Duration from "effect/Duration"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import * as PubSub from "effect/PubSub"; | ||
| 13 | import * as Queue from "effect/Queue"; | ||
| 14 | import * as Ref from "effect/Ref"; | ||
| 15 | import * as Schema from "effect/Schema"; | ||
| 16 | import * as Stream from "effect/Stream"; | ||
| 17 | |||
| 18 | import * as ElectronApp from "../electron/ElectronApp.ts"; | ||
| 19 | import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; | ||
| 20 | |||
| 21 | const SAMPLE_INTERVAL = Duration.seconds(1); | ||
| 22 | const IDLE_THRESHOLD_SECONDS = 60; | ||
| 23 | const encodeMessage = Schema.encodeSync(Schema.fromJsonString(DesktopHostTelemetryMessage)); | ||
| 24 | const textEncoder = new TextEncoder(); | ||
| 25 | |||
| 26 | type PowerEvent = | ||
| 27 | | { readonly type: "locked"; readonly value: boolean } | ||
| 28 | | { readonly type: "suspended"; readonly value: boolean } | ||
| 29 | | { readonly type: "onBattery"; readonly value: boolean } | ||
| 30 | | { readonly type: "thermal"; readonly value: HostPowerSnapshot["thermalState"] } | ||
| 31 | | { readonly type: "speedLimit"; readonly value: number }; | ||
| 32 | |||
| 33 | interface PowerState { | ||
| 34 | readonly locked: HostPowerSnapshot["locked"]; | ||
| 35 | readonly suspended: boolean; | ||
| 36 | readonly onBattery: HostPowerSnapshot["onBattery"]; | ||
| 37 | readonly thermalState: HostPowerSnapshot["thermalState"]; | ||
| 38 | readonly speedLimitPercent: Option.Option<number>; | ||
| 39 | } | ||
| 40 | |||
| 41 | export interface DesktopTelemetryPublisherShape { | ||
| 42 | readonly latest: Effect.Effect<Option.Option<DesktopHostTelemetrySnapshot>>; | ||
| 43 | readonly changes: Stream.Stream<DesktopHostTelemetrySnapshot>; | ||
| 44 | readonly encoded: Stream.Stream<Uint8Array>; | ||
| 45 | } | ||
| 46 | |||
| 47 | export class DesktopTelemetryPublisher extends Context.Service< | ||
| 48 | DesktopTelemetryPublisher, | ||
| 49 | DesktopTelemetryPublisherShape | ||
| 50 | >()("@t3tools/desktop/telemetry/DesktopTelemetryPublisher") {} | ||
| 51 | |||
| 52 | function booleanState(value: boolean): HostPowerSnapshot["onBattery"] { | ||
| 53 | return value ? "true" : "false"; | ||
| 54 | } | ||
| 55 | |||
| 56 | function idleState(value: ElectronPowerMonitor.ElectronIdleState): HostPowerSnapshot["idle"] { | ||
| 57 | switch (value) { | ||
| 58 | case "active": | ||
| 59 | return "false"; | ||
| 60 | case "idle": | ||
| 61 | case "locked": | ||
| 62 | return "true"; | ||
| 63 | case "unknown": | ||
| 64 | return "unknown"; | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | function updatePowerState(state: PowerState, event: PowerEvent): PowerState { | ||
| 69 | switch (event.type) { | ||
| 70 | case "locked": | ||
| 71 | return { ...state, locked: booleanState(event.value) }; | ||
| 72 | case "suspended": | ||
| 73 | return { ...state, suspended: event.value }; | ||
| 74 | case "onBattery": | ||
| 75 | return { ...state, onBattery: booleanState(event.value) }; | ||
| 76 | case "thermal": | ||
| 77 | return { ...state, thermalState: event.value }; | ||
| 78 | case "speedLimit": | ||
| 79 | return { ...state, speedLimitPercent: Option.some(event.value) }; | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { | ||
| 84 | const electronApp = yield* ElectronApp.ElectronApp; | ||
| 85 | const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor; | ||
| 86 | yield* electronApp.whenReady; | ||
| 87 | |||
| 88 | const initialPowerState: PowerState = { | ||
| 89 | locked: "unknown", | ||
| 90 | suspended: false, | ||
| 91 | onBattery: booleanState(yield* powerMonitor.isOnBatteryPower), | ||
| 92 | thermalState: yield* powerMonitor.getCurrentThermalState, | ||
| 93 | speedLimitPercent: Option.none(), | ||
| 94 | }; | ||
| 95 | const powerState = yield* Ref.make(initialPowerState); | ||
| 96 | const powerEvents = yield* Queue.unbounded<PowerEvent>(); | ||
| 97 | const latest = yield* Ref.make(Option.none<DesktopHostTelemetrySnapshot>()); | ||
| 98 | const changes = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(8); | ||
| 99 | const sequence = yield* Ref.make(0); | ||
| 100 | |||
| 101 | const offer = (event: PowerEvent): void => { | ||
| 102 | Queue.offerUnsafe(powerEvents, event); | ||
| 103 | }; | ||
| 104 | yield* Effect.all( | ||
| 105 | [ | ||
| 106 | powerMonitor.onSimpleEvent("lock-screen", () => offer({ type: "locked", value: true })), | ||
| 107 | powerMonitor.onSimpleEvent("unlock-screen", () => offer({ type: "locked", value: false })), | ||
| 108 | powerMonitor.onSimpleEvent("suspend", () => offer({ type: "suspended", value: true })), | ||
| 109 | powerMonitor.onSimpleEvent("resume", () => offer({ type: "suspended", value: false })), | ||
| 110 | powerMonitor.onSimpleEvent("on-battery", () => offer({ type: "onBattery", value: true })), | ||
| 111 | powerMonitor.onSimpleEvent("on-ac", () => offer({ type: "onBattery", value: false })), | ||
| 112 | powerMonitor.onThermalStateChange((value) => offer({ type: "thermal", value })), | ||
| 113 | powerMonitor.onSpeedLimitChange((value) => offer({ type: "speedLimit", value })), | ||
| 114 | ], | ||
| 115 | { concurrency: "unbounded" }, | ||
| 116 | ); | ||
| 117 | yield* Effect.forever( | ||
| 118 | Queue.take(powerEvents).pipe( | ||
| 119 | Effect.flatMap((event) => Ref.update(powerState, (state) => updatePowerState(state, event))), | ||
| 120 | ), | ||
| 121 | ).pipe(Effect.forkScoped); | ||
| 122 | |||
| 123 | const sampleOnce = Effect.gen(function* () { | ||
| 124 | const sampledAt = yield* DateTime.now; | ||
| 125 | const sampledAtUnixMs = DateTime.toEpochMillis(sampledAt); | ||
| 126 | const [currentPower, idleSeconds, systemIdleState, onBattery, metrics] = yield* Effect.all( | ||
| 127 | [ | ||
| 128 | Ref.get(powerState), | ||
| 129 | powerMonitor.getSystemIdleTime, | ||
| 130 | powerMonitor.getSystemIdleState(IDLE_THRESHOLD_SECONDS), | ||
| 131 | powerMonitor.isOnBatteryPower, | ||
| 132 | electronApp.getAppMetrics, | ||
| 133 | ], | ||
| 134 | { concurrency: "unbounded" }, | ||
| 135 | ); | ||
| 136 | const nextSequence = yield* Ref.modify(sequence, (current) => [current + 1, current + 1]); | ||
| 137 | const locked = systemIdleState === "locked" ? "true" : currentPower.locked; | ||
| 138 | const snapshot: DesktopHostTelemetrySnapshot = { | ||
| 139 | version: 1, | ||
| 140 | type: "desktopTelemetry", | ||
| 141 | sequence: nextSequence, | ||
| 142 | sampledAtUnixMs, | ||
| 143 | power: { | ||
| 144 | source: "electron-main", | ||
| 145 | idle: idleState(systemIdleState), | ||
| 146 | idleSeconds, | ||
| 147 | locked, | ||
| 148 | suspended: currentPower.suspended, | ||
| 149 | onBattery: booleanState(onBattery), | ||
| 150 | lowPowerMode: "unknown", | ||
| 151 | thermalState: currentPower.thermalState, | ||
| 152 | stale: false, | ||
| 153 | updatedAt: sampledAt, | ||
| 154 | }, | ||
| 155 | speedLimitPercent: currentPower.speedLimitPercent, | ||
| 156 | electronProcesses: metrics.map((metric) => ({ | ||
| 157 | pid: metric.pid, | ||
| 158 | creationTimeMs: metric.creationTime, | ||
| 159 | type: metric.type, | ||
| 160 | ...(metric.name === undefined ? {} : { name: metric.name }), | ||
| 161 | ...(metric.serviceName === undefined ? {} : { serviceName: metric.serviceName }), | ||
| 162 | cpuPercent: metric.cpu.percentCPUUsage, | ||
| 163 | ...(metric.cpu.cumulativeCPUUsage === undefined | ||
| 164 | ? {} | ||
| 165 | : { cumulativeCpuSeconds: metric.cpu.cumulativeCPUUsage }), | ||
| 166 | idleWakeupsPerSecond: metric.cpu.idleWakeupsPerSecond, | ||
| 167 | workingSetBytes: Math.max(0, Math.round(metric.memory.workingSetSize * 1024)), | ||
| 168 | peakWorkingSetBytes: Math.max(0, Math.round(metric.memory.peakWorkingSetSize * 1024)), | ||
| 169 | })), | ||
| 170 | }; | ||
| 171 | |||
| 172 | yield* Ref.set(latest, Option.some(snapshot)); | ||
| 173 | yield* PubSub.publish(changes, snapshot); | ||
| 174 | }).pipe( | ||
| 175 | Effect.catchCause((cause) => | ||
| 176 | Effect.logWarning("Failed to sample Electron telemetry", { | ||
| 177 | cause: String(cause), | ||
| 178 | }), | ||
| 179 | ), | ||
| 180 | ); | ||
| 181 | |||
| 182 | yield* Effect.forever(sampleOnce.pipe(Effect.andThen(Effect.sleep(SAMPLE_INTERVAL)))).pipe( | ||
| 183 | Effect.forkScoped, | ||
| 184 | ); | ||
| 185 | |||
| 186 | const snapshots = Stream.concat( | ||
| 187 | Stream.unwrap( | ||
| 188 | Ref.get(latest).pipe( | ||
| 189 | Effect.map( | ||
| 190 | Option.match({ | ||
| 191 | onNone: () => Stream.empty, | ||
| 192 | onSome: Stream.make, | ||
| 193 | }), | ||
| 194 | ), | ||
| 195 | ), | ||
| 196 | ), | ||
| 197 | Stream.fromPubSub(changes), | ||
| 198 | ); | ||
| 199 | const encoded = Stream.concat( | ||
| 200 | Stream.make({ | ||
| 201 | version: 1, | ||
| 202 | type: "desktopTelemetryHello", | ||
| 203 | electronPid: process.pid, | ||
| 204 | } as const), | ||
| 205 | snapshots, | ||
| 206 | ).pipe(Stream.map((message) => textEncoder.encode(`${encodeMessage(message)}\n`))); | ||
| 207 | |||
| 208 | return DesktopTelemetryPublisher.of({ | ||
| 209 | latest: Ref.get(latest), | ||
| 210 | changes: Stream.fromPubSub(changes), | ||
| 211 | encoded, | ||
| 212 | }); | ||
| 213 | }); | ||
| 214 | |||
| 215 | export const layer = Layer.effect(DesktopTelemetryPublisher, make()); | ||
| @@ -40,6 +40,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { · 39 unmodified lines | |||
| 40 | setAboutPanelOptions: () => Effect.void, | 40 | setAboutPanelOptions: () => Effect.void, |
| 41 | setAppUserModelId: () => Effect.void, | 41 | setAppUserModelId: () => Effect.void, |
| 42 | requestSingleInstanceLock: Effect.succeed(true), | 42 | requestSingleInstanceLock: Effect.succeed(true), |
| 43 | getAppMetrics: Effect.succeed([]), | ||
| 43 | isDefaultProtocolClient: () => Effect.succeed(false), | 44 | isDefaultProtocolClient: () => Effect.succeed(false), |
| 44 | setAsDefaultProtocolClient: () => Effect.succeed(true), | 45 | setAsDefaultProtocolClient: () => Effect.succeed(true), |
| 45 | setDesktopName: () => Effect.void, | 46 | setDesktopName: () => Effect.void, |
| @@ -0,0 +1,44 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Stream from "effect/Stream"; | ||
| 7 | |||
| 8 | import * as HostPowerMonitor from "./HostPowerMonitor.ts"; | ||
| 9 | |||
| 10 | describe("HostPowerMonitor", () => { | ||
| 11 | it.effect("publishes semantic power changes without idle-time heartbeat churn", () => | ||
| 12 | Effect.gen(function* () { | ||
| 13 | const monitor = yield* HostPowerMonitor.make(); | ||
| 14 | const initial = { | ||
| 15 | source: "electron-main", | ||
| 16 | idle: "false", | ||
| 17 | idleSeconds: 0, | ||
| 18 | locked: "false", | ||
| 19 | suspended: false, | ||
| 20 | onBattery: "false", | ||
| 21 | lowPowerMode: "unknown", | ||
| 22 | thermalState: "nominal", | ||
| 23 | stale: false, | ||
| 24 | updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"), | ||
| 25 | } as const; | ||
| 26 | yield* monitor.report(initial); | ||
| 27 | |||
| 28 | const nextChange = yield* Stream.runHead(monitor.streamChanges).pipe(Effect.forkChild); | ||
| 29 | yield* Effect.yieldNow; | ||
| 30 | yield* monitor.report({ | ||
| 31 | ...initial, | ||
| 32 | idleSeconds: 1, | ||
| 33 | updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:01.000Z"), | ||
| 34 | }); | ||
| 35 | yield* monitor.report({ | ||
| 36 | ...initial, | ||
| 37 | locked: "true", | ||
| 38 | updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:02.000Z"), | ||
| 39 | }); | ||
| 40 | |||
| 41 | expect(Option.getOrThrow(yield* Fiber.join(nextChange)).locked).toBe("true"); | ||
| 42 | }), | ||
| 43 | ); | ||
| 44 | }); | ||
| @@ -1,24 +1,13 @@ | |||
| 1 | import { | 1 | import type { HostPowerSnapshot } from "@t3tools/contracts"; |
| 2 | type BackgroundBooleanState, | ||
| 3 | type HostPowerSnapshot, | ||
| 4 | type HostPowerThermalState, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { | ||
| 7 | getBackgroundActivityPresetSettings, | ||
| 8 | resolveServerBackgroundActivitySettings, | ||
| 9 | } from "@t3tools/shared/backgroundActivitySettings"; | ||
| 10 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 11 | import * as Context from "effect/Context"; | 2 | import * as Context from "effect/Context"; |
| 12 | import * as DateTime from "effect/DateTime"; | 3 | import * as DateTime from "effect/DateTime"; |
| 13 | import * as Duration from "effect/Duration"; | ||
| 14 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 15 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 16 | import * as PubSub from "effect/PubSub"; | 6 | import * as PubSub from "effect/PubSub"; |
| 17 | import * as Ref from "effect/Ref"; | 7 | import * as Ref from "effect/Ref"; |
| 18 | import * as Stream from "effect/Stream"; | 8 | import * as Stream from "effect/Stream"; |
| 19 | 9 | ||
| 20 | import * as ProcessRunner from "../processRunner.ts"; | 10 | import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; |
| 21 | import { ServerSettingsService } from "../serverSettings.ts"; | ||
| 22 | 11 | ||
| 23 | export interface HostPowerMonitorShape { | 12 | export interface HostPowerMonitorShape { |
| 24 | readonly snapshot: Effect.Effect<HostPowerSnapshot>; | 13 | readonly snapshot: Effect.Effect<HostPowerSnapshot>; |
| @@ -31,8 +20,6 @@ export class HostPowerMonitor extends Context.Service<HostPowerMonitor, HostPowe · 6 unmodified lines | |||
| 31 | "t3/background/HostPowerMonitor", | 20 | "t3/background/HostPowerMonitor", |
| 32 | ) {} | 21 | ) {} |
| 33 | 22 | ||
| 34 | const COMMAND_TIMEOUT = Duration.seconds(3); | ||
| 35 | |||
| 36 | export const makeUnknownSnapshot = ( | 23 | export const makeUnknownSnapshot = ( |
| 37 | source: HostPowerSnapshot["source"], | 24 | source: HostPowerSnapshot["source"], |
| 38 | updatedAt: HostPowerSnapshot["updatedAt"], | 25 | updatedAt: HostPowerSnapshot["updatedAt"], |
| @@ -49,163 +36,52 @@ export const makeUnknownSnapshot = ( · 10 unmodified lines | |||
| 49 | updatedAt, | 36 | updatedAt, |
| 50 | }); | 37 | }); |
| 51 | 38 | ||
| 52 | function boolState(value: boolean | null): BackgroundBooleanState { | 39 | function samePowerState(left: HostPowerSnapshot, right: HostPowerSnapshot): boolean { |
| 53 | if (value === null) return "unknown"; | 40 | return ( |
| 54 | return value ? "true" : "false"; | 41 | left.source === right.source && |
| 55 | } | 42 | left.idle === right.idle && |
| 56 | 43 | left.locked === right.locked && | |
| 57 | function parseIdleSeconds(ioregOutput: string): number | null { | 44 | left.suspended === right.suspended && |
| 58 | const match = /"HIDIdleTime"\s*=\s*(\d+)/u.exec(ioregOutput); | 45 | left.onBattery === right.onBattery && |
| 59 | if (!match) return null; | 46 | left.lowPowerMode === right.lowPowerMode && |
| 60 | const nanoseconds = Number(match[1]); | 47 | left.thermalState === right.thermalState && |
| 61 | return Number.isFinite(nanoseconds) ? Math.floor(nanoseconds / 1_000_000_000) : null; | 48 | left.stale === right.stale |
| 62 | } | ||
| 63 | |||
| 64 | function parseOnBattery(pmsetBatteryOutput: string): boolean | null { | ||
| 65 | if (/Now drawing from 'Battery Power'/iu.test(pmsetBatteryOutput)) return true; | ||
| 66 | if (/Now drawing from 'AC Power'/iu.test(pmsetBatteryOutput)) return false; | ||
| 67 | return null; | ||
| 68 | } | ||
| 69 | |||
| 70 | function parseLowPowerMode(pmsetOutput: string): boolean | null { | ||
| 71 | const match = /^\s*lowpowermode\s+([01])\s*$/imu.exec(pmsetOutput); | ||
| 72 | if (!match) return null; | ||
| 73 | return match[1] === "1"; | ||
| 74 | } | ||
| 75 | |||
| 76 | function parseThermalState(_output: string): HostPowerThermalState { | ||
| 77 | // The stable shell adapter intentionally does not parse `pmset thermlog`; | ||
| 78 | // native adapters can provide this without depending on human-formatted text. | ||
| 79 | return "unknown"; | ||
| 80 | } | ||
| 81 | |||
| 82 | function runOptional( | ||
| 83 | runner: ProcessRunner.ProcessRunnerShape, | ||
| 84 | command: string, | ||
| 85 | args: ReadonlyArray<string>, | ||
| 86 | ) { | ||
| 87 | return runner | ||
| 88 | .run({ | ||
| 89 | command, | ||
| 90 | args, | ||
| 91 | timeout: COMMAND_TIMEOUT, | ||
| 92 | timeoutBehavior: "timedOutResult", | ||
| 93 | outputMode: "truncate", | ||
| 94 | maxOutputBytes: 32_000, | ||
| 95 | }) | ||
| 96 | .pipe(Effect.option); | ||
| 97 | } | ||
| 98 | |||
| 99 | const readMacShellSnapshot = Effect.fn("background.hostPower.readMacShellSnapshot")(function* () { | ||
| 100 | const runner = yield* ProcessRunner.ProcessRunner; | ||
| 101 | const updatedAt = yield* DateTime.now; | ||
| 102 | const [idleOutput, batteryOutput, pmsetOutput] = yield* Effect.all( | ||
| 103 | [ | ||
| 104 | runOptional(runner, "ioreg", ["-c", "IOHIDSystem"]), | ||
| 105 | runOptional(runner, "pmset", ["-g", "batt"]), | ||
| 106 | runOptional(runner, "pmset", ["-g"]), | ||
| 107 | ], | ||
| 108 | { concurrency: "unbounded" }, | ||
| 109 | ); | 49 | ); |
| 110 | 50 | } | |
| 111 | const idleSeconds = idleOutput._tag === "Some" ? parseIdleSeconds(idleOutput.value.stdout) : null; | ||
| 112 | const onBattery = | ||
| 113 | batteryOutput._tag === "Some" ? parseOnBattery(batteryOutput.value.stdout) : null; | ||
| 114 | const lowPowerMode = | ||
| 115 | pmsetOutput._tag === "Some" ? parseLowPowerMode(pmsetOutput.value.stdout) : null; | ||
| 116 | |||
| 117 | return { | ||
| 118 | source: "node-macos-shell", | ||
| 119 | idle: boolState(idleSeconds === null ? null : idleSeconds >= 60), | ||
| 120 | idleSeconds, | ||
| 121 | locked: "unknown", | ||
| 122 | suspended: false, | ||
| 123 | onBattery: boolState(onBattery), | ||
| 124 | lowPowerMode: boolState(lowPowerMode), | ||
| 125 | thermalState: parseThermalState(""), | ||
| 126 | stale: false, | ||
| 127 | updatedAt, | ||
| 128 | } satisfies HostPowerSnapshot; | ||
| 129 | }); | ||
| 130 | 51 | ||
| 131 | export const make = Effect.fn("background.hostPower.make")(function* ( | 52 | export const make = Effect.fn("background.hostPower.make")(function* ( |
| 132 | initialSource: HostPowerSnapshot["source"] = "unknown", | 53 | initialSource: HostPowerSnapshot["source"] = "unknown", |
| 133 | ) { | 54 | ) { |
| 134 | const initial = makeUnknownSnapshot(initialSource, yield* DateTime.now); | 55 | const initial = makeUnknownSnapshot(initialSource, yield* DateTime.now); |
| 135 | const latestRef = yield* Ref.make(initial); | 56 | const latestRef = yield* Ref.make(initial); |
| 136 | const demandActiveRef = yield* Ref.make(false); | ||
| 137 | const changes = yield* PubSub.sliding<HostPowerSnapshot>(1); | 57 | const changes = yield* PubSub.sliding<HostPowerSnapshot>(1); |
| 138 | 58 | ||
| 139 | const report: HostPowerMonitorShape["report"] = (snapshot) => | 59 | const report: HostPowerMonitorShape["report"] = (snapshot) => |
| 140 | Ref.set(latestRef, snapshot).pipe( | 60 | Ref.modify(latestRef, (current) => [!samePowerState(current, snapshot), snapshot]).pipe( |
| 141 | Effect.andThen(PubSub.publish(changes, snapshot)), | 61 | Effect.flatMap((changed) => (changed ? PubSub.publish(changes, snapshot) : Effect.void)), |
| 142 | Effect.asVoid, | 62 | Effect.asVoid, |
| 143 | ); | 63 | ); |
| 144 | 64 | ||
| 145 | return HostPowerMonitor.of({ | 65 | return HostPowerMonitor.of({ |
| 146 | snapshot: Ref.get(latestRef), | 66 | snapshot: Ref.get(latestRef), |
| 147 | report, | 67 | report, |
| 148 | setDemandActive: (active) => Ref.set(demandActiveRef, active), | 68 | setDemandActive: () => Effect.void, |
| 149 | streamChanges: Stream.fromPubSub(changes), | 69 | streamChanges: Stream.fromPubSub(changes), |
| 150 | }); | 70 | }); |
| 151 | }); | 71 | }); |
| 152 | 72 | ||
| 153 | const unknownLayer = Layer.effect(HostPowerMonitor, make("unknown")); | 73 | export const layer = Layer.effect( |
| 154 | const linuxLayer = Layer.effect(HostPowerMonitor, make("node-linux")); | ||
| 155 | const windowsLayer = Layer.effect(HostPowerMonitor, make("node-windows")); | ||
| 156 | |||
| 157 | const macShellLayer = Layer.effect( | ||
| 158 | HostPowerMonitor, | 74 | HostPowerMonitor, |
| 159 | Effect.gen(function* () { | 75 | Effect.gen(function* () { |
| 160 | const serverSettings = yield* ServerSettingsService; | 76 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; |
| 161 | const monitor = yield* make("node-macos-shell"); | 77 | const initial = yield* telemetry.latest; |
| 162 | const demandActiveRef = yield* Ref.make(true); | 78 | const monitor = yield* make(initial.power.source); |
| 163 | const setDemandActive: HostPowerMonitorShape["setDemandActive"] = (active) => | 79 | yield* monitor.report(initial.power); |
| 164 | Ref.set(demandActiveRef, active); | 80 | yield* telemetry.changes.pipe( |
| 165 | const getPollInterval = Effect.gen(function* () { | 81 | Stream.map((snapshot) => snapshot.power), |
| 166 | const demandActive = yield* Ref.get(demandActiveRef); | 82 | Stream.runForEach(monitor.report), |
| 167 | const settings = yield* serverSettings.getSettings.pipe( | 83 | Effect.forkScoped, |
| 168 | Effect.map(resolveServerBackgroundActivitySettings), | ||
| 169 | Effect.catch(() => Effect.succeed(getBackgroundActivityPresetSettings("balanced"))), | ||
| 170 | ); | ||
| 171 | return demandActive | ||
| 172 | ? settings.hostPowerMonitorActiveInterval | ||
| 173 | : settings.hostPowerMonitorIdleInterval; | ||
| 174 | }); | ||
| 175 | const adaptiveMonitor = HostPowerMonitor.of({ | ||
| 176 | snapshot: monitor.snapshot, | ||
| 177 | report: monitor.report, | ||
| 178 | setDemandActive, | ||
| 179 | streamChanges: monitor.streamChanges, | ||
| 180 | }); | ||
| 181 | yield* readMacShellSnapshot().pipe( | ||
| 182 | Effect.flatMap(adaptiveMonitor.report), | ||
| 183 | Effect.ignoreCause({ log: true }), | ||
| 184 | ); | 84 | ); |
| 185 | yield* Effect.forever( | 85 | return monitor; |
| 186 | getPollInterval.pipe( | ||
| 187 | Effect.flatMap((interval) => Effect.sleep(Duration.max(interval, Duration.seconds(5)))), | ||
| 188 | Effect.andThen(readMacShellSnapshot()), | ||
| 189 | Effect.flatMap(adaptiveMonitor.report), | ||
| 190 | Effect.ignoreCause({ log: true }), | ||
| 191 | ), | ||
| 192 | ).pipe(Effect.forkScoped); | ||
| 193 | return adaptiveMonitor; | ||
| 194 | }), | ||
| 195 | ).pipe(Layer.provide(ProcessRunner.layer)); | ||
| 196 | |||
| 197 | export const layer = Layer.unwrap( | ||
| 198 | Effect.gen(function* () { | ||
| 199 | const platform = yield* HostProcessPlatform; | ||
| 200 | switch (platform) { | ||
| 201 | case "darwin": | ||
| 202 | return macShellLayer; | ||
| 203 | case "linux": | ||
| 204 | return linuxLayer; | ||
| 205 | case "win32": | ||
| 206 | return windowsLayer; | ||
| 207 | default: | ||
| 208 | return unknownLayer; | ||
| 209 | } | ||
| 210 | }), | 86 | }), |
| 211 | ); | 87 | ); |
| @@ -298,6 +298,8 @@ export const resolveServerConfig = ( · 297 unmodified lines | |||
| 298 | () => mode === "desktop", | 298 | () => mode === "desktop", |
| 299 | ); | 299 | ); |
| 300 | const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; | 300 | const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; |
| 301 | const desktopTelemetryFd = bootstrap?.desktopTelemetryFd; | ||
| 302 | const resourceMonitorPath = bootstrap?.resourceMonitorPath; | ||
| 301 | const autoBootstrapProjectFromCwd = Option.getOrElse( | 303 | const autoBootstrapProjectFromCwd = Option.getOrElse( |
| 302 | resolveOptionPrecedence( | 304 | resolveOptionPrecedence( |
| 303 | Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), | 305 | Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), |
| @@ -370,6 +372,8 @@ export const resolveServerConfig = ( · 66 unmodified lines | |||
| 370 | noBrowser, | 372 | noBrowser, |
| 371 | startupPresentation, | 373 | startupPresentation, |
| 372 | desktopBootstrapToken, | 374 | desktopBootstrapToken, |
| 375 | desktopTelemetryFd, | ||
| 376 | resourceMonitorPath, | ||
| 373 | autoBootstrapProjectFromCwd, | 377 | autoBootstrapProjectFromCwd, |
| 374 | logWebSocketEvents, | 378 | logWebSocketEvents, |
| 375 | tailscaleServeEnabled, | 379 | tailscaleServeEnabled, |
| @@ -69,6 +69,8 @@ export interface ServerConfigShape extends ServerDerivedPaths { · 68 unmodified lines | |||
| 69 | readonly noBrowser: boolean; | 69 | readonly noBrowser: boolean; |
| 70 | readonly startupPresentation: StartupPresentation; | 70 | readonly startupPresentation: StartupPresentation; |
| 71 | readonly desktopBootstrapToken: string | undefined; | 71 | readonly desktopBootstrapToken: string | undefined; |
| 72 | readonly desktopTelemetryFd?: number | undefined; | ||
| 73 | readonly resourceMonitorPath?: string | undefined; | ||
| 72 | readonly autoBootstrapProjectFromCwd: boolean; | 74 | readonly autoBootstrapProjectFromCwd: boolean; |
| 73 | readonly logWebSocketEvents: boolean; | 75 | readonly logWebSocketEvents: boolean; |
| 74 | readonly tailscaleServeEnabled: boolean; | 76 | readonly tailscaleServeEnabled: boolean; |
| @@ -171,6 +173,8 @@ export class ServerConfig extends Context.Service<ServerConfig, ServerConfigShap · 96 unmodified lines | |||
| 171 | port: 0, | 173 | port: 0, |
| 172 | host: undefined, | 174 | host: undefined, |
| 173 | desktopBootstrapToken: undefined, | 175 | desktopBootstrapToken: undefined, |
| 176 | desktopTelemetryFd: undefined, | ||
| 177 | resourceMonitorPath: undefined, | ||
| 174 | staticDir: undefined, | 178 | staticDir: undefined, |
| 175 | devUrl, | 179 | devUrl, |
| 176 | noBrowser: false, | 180 | noBrowser: false, |
| @@ -1,251 +1,217 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import type { | ||
| 3 | DesktopHostTelemetrySnapshot, | ||
| 4 | ResourceMonitorSnapshotEvent, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | 6 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 9 | import * as Option from "effect/Option"; |
| 6 | import * as Sink from "effect/Sink"; | ||
| 7 | import * as Stream from "effect/Stream"; | ||
| 8 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 9 | 10 | ||
| 11 | import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; | ||
| 12 | import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; | ||
| 13 | import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; | ||
| 14 | import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; | ||
| 10 | import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; | 15 | import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; |
| 11 | 16 | ||
| 12 | const encoder = new TextEncoder(); | 17 | function makeNativeSnapshot( |
| 18 | processes: ResourceMonitorSnapshotEvent["processes"], | ||
| 19 | ): ResourceMonitorSnapshotEvent { | ||
| 20 | return { | ||
| 21 | version: 1, | ||
| 22 | type: "snapshot", | ||
| 23 | sequence: 1, | ||
| 24 | sampledAtUnixMs: DateTime.toEpochMillis(DateTime.makeUnsafe("2026-05-05T10:00:00.000Z")), | ||
| 25 | collectionDurationMicros: 250, | ||
| 26 | scannedProcessCount: processes.length, | ||
| 27 | retainedProcessCount: processes.length, | ||
| 28 | inaccessibleProcessCount: 0, | ||
| 29 | processes, | ||
| 30 | }; | ||
| 31 | } | ||
| 13 | 32 | ||
| 14 | function mockHandle(result: { | 33 | function makeTelemetryLayer( |
| 15 | readonly stdout?: string; | 34 | snapshot: ResourceMonitorSnapshotEvent, |
| 16 | readonly stderr?: string; | 35 | desktopSnapshot?: DesktopHostTelemetrySnapshot, |
| 17 | readonly code?: number; | 36 | ) { |
| 18 | }) { | 37 | const nativeLayer = NativeTelemetryClient.layerTest({ |
| 19 | return ChildProcessSpawner.makeHandle({ | 38 | sampleNow: Effect.succeed(snapshot), |
| 20 | pid: ChildProcessSpawner.ProcessId(1), | 39 | health: Effect.succeed({ |
| 21 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), | 40 | status: "healthy", |
| 22 | isRunning: Effect.succeed(false), | 41 | hello: Option.none(), |
| 23 | kill: () => Effect.void, | 42 | lastSampleAt: Option.some(DateTime.makeUnsafe(snapshot.sampledAtUnixMs)), |
| 24 | unref: Effect.succeed(Effect.void), | 43 | lastError: Option.none(), |
| 25 | stdin: Sink.drain, | 44 | restartCount: 0, |
| 26 | stdout: Stream.make(encoder.encode(result.stdout ?? "")), | 45 | }), |
| 27 | stderr: Stream.make(encoder.encode(result.stderr ?? "")), | ||
| 28 | all: Stream.empty, | ||
| 29 | getInputFd: () => Sink.drain, | ||
| 30 | getOutputFd: () => Stream.empty, | ||
| 31 | }); | 46 | }); |
| 47 | const desktopLayer = desktopSnapshot | ||
| 48 | ? DesktopTelemetryReceiver.layerTest({ | ||
| 49 | latest: Effect.succeedSome(desktopSnapshot), | ||
| 50 | health: Effect.succeed({ | ||
| 51 | status: "healthy", | ||
| 52 | lastSampleAt: Option.some(DateTime.makeUnsafe(desktopSnapshot.sampledAtUnixMs)), | ||
| 53 | lastError: Option.none(), | ||
| 54 | }), | ||
| 55 | }) | ||
| 56 | : DesktopTelemetryReceiver.layerTest(); | ||
| 57 | return ResourceTelemetry.layer.pipe( | ||
| 58 | Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, ResourceAttribution.layer)), | ||
| 59 | ); | ||
| 32 | } | 60 | } |
| 33 | 61 | ||
| 34 | describe("ProcessDiagnostics", () => { | 62 | describe("ProcessDiagnostics", () => { |
| 35 | it.effect("parses POSIX ps rows with full commands", () => | 63 | it.effect("projects live process data from resource telemetry", () => |
| 36 | Effect.sync(() => { | 64 | Effect.gen(function* () { |
| 37 | const rows = ProcessDiagnostics.parsePosixProcessRows( | 65 | const snapshot = makeNativeSnapshot([ |
| 38 | [ | ||
| 39 | " 10 1 10 Ss 0.0 1024 01:02.03 /usr/bin/node server.js", | ||
| 40 | " 11 10 10 S+ 12.5 20480 00:04 codex app-server --config /tmp/one two", | ||
| 41 | ].join("\n"), | ||
| 42 | ); | ||
| 43 | |||
| 44 | expect(rows).toEqual([ | ||
| 45 | { | 66 | { |
| 46 | pid: 10, | 67 | pid: process.pid, |
| 47 | ppid: 1, | 68 | ppid: 1, |
| 48 | pgid: 10, | 69 | startTimeMs: 1_000, |
| 49 | status: "Ss", | 70 | runTimeMs: 60_000, |
| 71 | name: "node", | ||
| 72 | command: "t3 server", | ||
| 73 | status: "Running", | ||
| 50 | cpuPercent: 0, | 74 | cpuPercent: 0, |
| 51 | rssBytes: 1024 * 1024, | 75 | cpuTimeMs: 100, |
| 52 | elapsed: "01:02.03", | 76 | residentBytes: 1_024, |
| 53 | command: "/usr/bin/node server.js", | 77 | virtualBytes: 2_048, |
| 78 | ioReadBytes: 100, | ||
| 79 | ioWriteBytes: 200, | ||
| 80 | ioSemantics: "storage", | ||
| 54 | }, | 81 | }, |
| 55 | { | 82 | { |
| 56 | pid: 11, | 83 | pid: 4_242, |
| 57 | ppid: 10, | 84 | ppid: process.pid, |
| 58 | pgid: 10, | 85 | startTimeMs: 2_000, |
| 59 | status: "S+", | 86 | runTimeMs: 4_000, |
| 60 | cpuPercent: 12.5, | 87 | name: "agent", |
| 61 | rssBytes: 20480 * 1024, | 88 | command: "codex app-server", |
| 62 | elapsed: "00:04", | 89 | status: "Running", |
| 63 | command: "codex app-server --config /tmp/one two", | 90 | cpuPercent: 1.5, |
| 91 | cpuTimeMs: 60, | ||
| 92 | residentBytes: 2_048, | ||
| 93 | virtualBytes: 4_096, | ||
| 94 | ioReadBytes: 300, | ||
| 95 | ioWriteBytes: 400, | ||
| 96 | ioSemantics: "storage", | ||
| 64 | }, | 97 | }, |
| 65 | ]); | 98 | ]); |
| 66 | }), | 99 | const telemetryLayer = makeTelemetryLayer(snapshot); |
| 67 | ); | 100 | const layer = ProcessDiagnostics.layer.pipe(Layer.provideMerge(telemetryLayer)); |
| 68 | 101 | ||
| 69 | it.effect("aggregates only descendants of the server process", () => | 102 | const diagnostics = yield* Effect.gen(function* () { |
| 70 | Effect.sync(() => { | 103 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; |
| 71 | const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({ | 104 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; |
| 72 | serverPid: 100, | 105 | yield* telemetry.refresh; |
| 73 | readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), | 106 | return yield* processDiagnostics.read; |
| 74 | rows: [ | 107 | }).pipe(Effect.provide(layer)); |
| 75 | { | ||
| 76 | pid: 100, | ||
| 77 | ppid: 1, | ||
| 78 | pgid: 100, | ||
| 79 | status: "S", | ||
| 80 | cpuPercent: 0, | ||
| 81 | rssBytes: 1_000, | ||
| 82 | elapsed: "01:00", | ||
| 83 | command: "t3 server", | ||
| 84 | }, | ||
| 85 | { | ||
| 86 | pid: 101, | ||
| 87 | ppid: 100, | ||
| 88 | pgid: 100, | ||
| 89 | status: "S", | ||
| 90 | cpuPercent: 1.5, | ||
| 91 | rssBytes: 2_000, | ||
| 92 | elapsed: "00:20", | ||
| 93 | command: "codex app-server", | ||
| 94 | }, | ||
| 95 | { | ||
| 96 | pid: 102, | ||
| 97 | ppid: 101, | ||
| 98 | pgid: 100, | ||
| 99 | status: "R", | ||
| 100 | cpuPercent: 3.25, | ||
| 101 | rssBytes: 4_000, | ||
| 102 | elapsed: "00:05", | ||
| 103 | command: "git status", | ||
| 104 | }, | ||
| 105 | { | ||
| 106 | pid: 200, | ||
| 107 | ppid: 1, | ||
| 108 | pgid: 200, | ||
| 109 | status: "S", | ||
| 110 | cpuPercent: 99, | ||
| 111 | rssBytes: 8_000, | ||
| 112 | elapsed: "00:01", | ||
| 113 | command: "unrelated", | ||
| 114 | }, | ||
| 115 | { | ||
| 116 | pid: 201, | ||
| 117 | ppid: 100, | ||
| 118 | pgid: 100, | ||
| 119 | status: "R", | ||
| 120 | cpuPercent: 9, | ||
| 121 | rssBytes: 9_000, | ||
| 122 | elapsed: "00:00", | ||
| 123 | command: "ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=", | ||
| 124 | }, | ||
| 125 | ], | ||
| 126 | }); | ||
| 127 | |||
| 128 | expect(diagnostics.serverPid).toBe(100); | ||
| 129 | expect(DateTime.formatIso(diagnostics.readAt)).toBe("2026-05-05T10:00:00.000Z"); | ||
| 130 | expect(diagnostics.processCount).toBe(2); | ||
| 131 | expect(diagnostics.totalRssBytes).toBe(6_000); | ||
| 132 | expect(diagnostics.totalCpuPercent).toBe(4.75); | ||
| 133 | expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102]); | ||
| 134 | expect(diagnostics.processes.map((process) => process.depth)).toEqual([0, 1]); | ||
| 135 | expect(Option.getOrNull(diagnostics.processes[0]!.pgid)).toBe(100); | ||
| 136 | expect(diagnostics.processes[0]?.childPids).toEqual([102]); | ||
| 137 | }), | ||
| 138 | ); | ||
| 139 | |||
| 140 | it.effect("preserves ascending sibling order for nested descendants", () => | ||
| 141 | Effect.sync(() => { | ||
| 142 | const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({ | ||
| 143 | serverPid: 100, | ||
| 144 | readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), | ||
| 145 | rows: [ | ||
| 146 | { | ||
| 147 | pid: 101, | ||
| 148 | ppid: 100, | ||
| 149 | pgid: 100, | ||
| 150 | status: "S", | ||
| 151 | cpuPercent: 0, | ||
| 152 | rssBytes: 100, | ||
| 153 | elapsed: "00:10", | ||
| 154 | command: "agent", | ||
| 155 | }, | ||
| 156 | { | ||
| 157 | pid: 103, | ||
| 158 | ppid: 101, | ||
| 159 | pgid: 100, | ||
| 160 | status: "S", | ||
| 161 | cpuPercent: 0, | ||
| 162 | rssBytes: 100, | ||
| 163 | elapsed: "00:10", | ||
| 164 | command: "child-b", | ||
| 165 | }, | ||
| 166 | { | ||
| 167 | pid: 102, | ||
| 168 | ppid: 101, | ||
| 169 | pgid: 100, | ||
| 170 | status: "S", | ||
| 171 | cpuPercent: 0, | ||
| 172 | rssBytes: 100, | ||
| 173 | elapsed: "00:10", | ||
| 174 | command: "child-a", | ||
| 175 | }, | ||
| 176 | ], | ||
| 177 | }); | ||
| 178 | 108 | ||
| 179 | expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102, 103]); | 109 | expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]); |
| 110 | expect(diagnostics.processes[0]?.startTimeMs).toBe(2_000); | ||
| 111 | expect(diagnostics.processes[0]?.cpuPercent).toBe(1.5); | ||
| 112 | expect(diagnostics.processes[0]?.rssBytes).toBe(2_048); | ||
| 180 | }), | 113 | }), |
| 181 | ); | 114 | ); |
| 182 | 115 | ||
| 183 | it.effect("queries processes through the ChildProcessSpawner service", () => | 116 | it.effect("rejects stale process identities before signaling", () => |
| 184 | Effect.gen(function* () { | 117 | Effect.gen(function* () { |
| 185 | const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = | 118 | const snapshot = makeNativeSnapshot([]); |
| 186 | []; | 119 | const telemetryLayer = makeTelemetryLayer(snapshot); |
| 187 | const spawnerLayer = Layer.succeed( | 120 | const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer)); |
| 188 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 189 | ChildProcessSpawner.make((command) => { | ||
| 190 | const childProcess = command as unknown as { | ||
| 191 | readonly command: string; | ||
| 192 | readonly args: ReadonlyArray<string>; | ||
| 193 | }; | ||
| 194 | commands.push({ command: childProcess.command, args: childProcess.args }); | ||
| 195 | return Effect.succeed( | ||
| 196 | mockHandle({ | ||
| 197 | stdout: [ | ||
| 198 | ` ${process.pid} 1 ${process.pid} Ss 0.0 1024 01:02.03 t3 server`, | ||
| 199 | ` 4242 ${process.pid} ${process.pid} S 1.5 2048 00:04 agent`, | ||
| 200 | ].join("\n"), | ||
| 201 | }), | ||
| 202 | ); | ||
| 203 | }), | ||
| 204 | ); | ||
| 205 | const layer = ProcessDiagnostics.layer.pipe(Layer.provide(spawnerLayer)); | ||
| 206 | 121 | ||
| 207 | const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( | 122 | const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( |
| 208 | Effect.flatMap((pd) => pd.read), | 123 | Effect.flatMap((processDiagnostics) => |
| 124 | processDiagnostics.signal({ | ||
| 125 | pid: 4_242, | ||
| 126 | startTimeMs: 2_000, | ||
| 127 | signal: "SIGINT", | ||
| 128 | }), | ||
| 129 | ), | ||
| 209 | Effect.provide(layer), | 130 | Effect.provide(layer), |
| 210 | ); | 131 | ); |
| 211 | 132 | ||
| 212 | expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]); | 133 | expect(result).toEqual({ |
| 213 | expect(commands).toEqual([ | 134 | pid: 4242, |
| 214 | { | 135 | signal: "SIGINT", |
| 215 | command: "ps", | 136 | signaled: false, |
| 216 | args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="], | 137 | message: Option.some("Process 4242 no longer matches the selected process identity."), |
| 217 | }, | 138 | }); |
| 218 | ]); | ||
| 219 | }), | 139 | }), |
| 220 | ); | 140 | ); |
| 221 | 141 | ||
| 222 | it.effect("does not allow signaling the diagnostics query process", () => | 142 | it.effect("rejects Electron processes as signal targets", () => |
| 223 | Effect.gen(function* () { | 143 | Effect.gen(function* () { |
| 224 | const spawnerLayer = Layer.succeed( | 144 | const sampledAtUnixMs = DateTime.toEpochMillis( |
| 225 | ChildProcessSpawner.ChildProcessSpawner, | 145 | DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), |
| 226 | ChildProcessSpawner.make(() => | ||
| 227 | Effect.succeed( | ||
| 228 | mockHandle({ | ||
| 229 | stdout: [ | ||
| 230 | ` ${process.pid} 1 ${process.pid} Ss 0.0 1024 01:02.03 t3 server`, | ||
| 231 | ` 4242 ${process.pid} ${process.pid} R 1.5 2048 00:00 ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=`, | ||
| 232 | ].join("\n"), | ||
| 233 | }), | ||
| 234 | ), | ||
| 235 | ), | ||
| 236 | ); | 146 | ); |
| 237 | const layer = ProcessDiagnostics.layer.pipe(Layer.provide(spawnerLayer)); | 147 | const snapshot = makeNativeSnapshot([ |
| 148 | { | ||
| 149 | pid: 4_242, | ||
| 150 | ppid: 1, | ||
| 151 | startTimeMs: 2_000, | ||
| 152 | runTimeMs: 4_000, | ||
| 153 | name: "electron", | ||
| 154 | command: "electron", | ||
| 155 | status: "Running", | ||
| 156 | cpuPercent: 1.5, | ||
| 157 | cpuTimeMs: 60, | ||
| 158 | residentBytes: 2_048, | ||
| 159 | virtualBytes: 4_096, | ||
| 160 | ioReadBytes: 300, | ||
| 161 | ioWriteBytes: 400, | ||
| 162 | ioSemantics: "storage", | ||
| 163 | }, | ||
| 164 | ]); | ||
| 165 | const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs); | ||
| 166 | const telemetryLayer = makeTelemetryLayer(snapshot, { | ||
| 167 | version: 1, | ||
| 168 | type: "desktopTelemetry", | ||
| 169 | sequence: 1, | ||
| 170 | sampledAtUnixMs, | ||
| 171 | power: { | ||
| 172 | source: "electron-main", | ||
| 173 | idle: "false", | ||
| 174 | idleSeconds: 0, | ||
| 175 | locked: "false", | ||
| 176 | suspended: false, | ||
| 177 | onBattery: "false", | ||
| 178 | lowPowerMode: "unknown", | ||
| 179 | thermalState: "nominal", | ||
| 180 | stale: false, | ||
| 181 | updatedAt: sampledAt, | ||
| 182 | }, | ||
| 183 | speedLimitPercent: Option.none(), | ||
| 184 | electronProcesses: [ | ||
| 185 | { | ||
| 186 | pid: 4_242, | ||
| 187 | creationTimeMs: 2_000, | ||
| 188 | type: "Browser", | ||
| 189 | name: "electron", | ||
| 190 | cpuPercent: 1.5, | ||
| 191 | idleWakeupsPerSecond: 0, | ||
| 192 | workingSetBytes: 2_048, | ||
| 193 | peakWorkingSetBytes: 2_048, | ||
| 194 | }, | ||
| 195 | ], | ||
| 196 | }); | ||
| 197 | const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer)); | ||
| 238 | 198 | ||
| 239 | const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( | 199 | const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( |
| 240 | Effect.flatMap((pd) => pd.signal({ pid: 4242, signal: "SIGINT" })), | 200 | Effect.flatMap((processDiagnostics) => |
| 201 | processDiagnostics.signal({ | ||
| 202 | pid: 4_242, | ||
| 203 | startTimeMs: 2_000, | ||
| 204 | signal: "SIGKILL", | ||
| 205 | }), | ||
| 206 | ), | ||
| 241 | Effect.provide(layer), | 207 | Effect.provide(layer), |
| 242 | ); | 208 | ); |
| 243 | 209 | ||
| 244 | expect(result).toEqual({ | 210 | expect(result).toEqual({ |
| 245 | pid: 4242, | 211 | pid: 4_242, |
| 246 | signal: "SIGINT", | 212 | signal: "SIGKILL", |
| 247 | signaled: false, | 213 | signaled: false, |
| 248 | message: Option.some("Process 4242 is not a live descendant of the T3 server."), | 214 | message: Option.some("Process 4242 is not a signalable T3 backend descendant."), |
| 249 | }); | 215 | }); |
| 250 | }), | 216 | }), |
| 251 | ); | 217 | ); |
| @@ -1,40 +1,23 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | ResourceTelemetryProcessCategory, | ||
| 2 | ServerProcessDiagnosticsEntry, | 3 | ServerProcessDiagnosticsEntry, |
| 3 | ServerProcessDiagnosticsResult, | 4 | ServerProcessDiagnosticsResult, |
| 4 | ServerProcessSignal, | 5 | ServerProcessSignal, |
| 5 | ServerSignalProcessResult, | 6 | ServerSignalProcessResult, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 7 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 8 | import * as Context from "effect/Context"; | 8 | import * as Context from "effect/Context"; |
| 9 | import * as DateTime from "effect/DateTime"; | ||
| 10 | import * as Duration from "effect/Duration"; | ||
| 11 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| 13 | import * as Option from "effect/Option"; | 11 | import * as Option from "effect/Option"; |
| 14 | import * as Schema from "effect/Schema"; | 12 | import * as Schema from "effect/Schema"; |
| 15 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 16 | 13 | ||
| 17 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | 14 | import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; |
| 18 | |||
| 19 | export interface ProcessRow { | ||
| 20 | readonly pid: number; | ||
| 21 | readonly ppid: number; | ||
| 22 | readonly pgid: number | null; | ||
| 23 | readonly status: string; | ||
| 24 | readonly cpuPercent: number; | ||
| 25 | readonly rssBytes: number; | ||
| 26 | readonly elapsed: string; | ||
| 27 | readonly command: string; | ||
| 28 | } | ||
| 29 | |||
| 30 | const PROCESS_QUERY_TIMEOUT_MS = 1_000; | ||
| 31 | const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="; | ||
| 32 | const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; | ||
| 33 | 15 | ||
| 34 | export interface ProcessDiagnosticsShape { | 16 | export interface ProcessDiagnosticsShape { |
| 35 | readonly read: Effect.Effect<ServerProcessDiagnosticsResult>; | 17 | readonly read: Effect.Effect<ServerProcessDiagnosticsResult>; |
| 36 | readonly signal: (input: { | 18 | readonly signal: (input: { |
| 37 | readonly pid: number; | 19 | readonly pid: number; |
| 20 | readonly startTimeMs: number; | ||
| 38 | readonly signal: ServerProcessSignal; | 21 | readonly signal: ServerProcessSignal; |
| 39 | }) => Effect.Effect<ServerSignalProcessResult>; | 22 | }) => Effect.Effect<ServerSignalProcessResult>; |
| 40 | } | 23 | } |
| @@ -44,420 +27,161 @@ export class ProcessDiagnostics extends Context.Service< · 3 unmodified lines | |||
| 44 | ProcessDiagnosticsShape | 27 | ProcessDiagnosticsShape |
| 45 | >()("t3/diagnostics/ProcessDiagnostics") {} | 28 | >()("t3/diagnostics/ProcessDiagnostics") {} |
| 46 | 29 | ||
| 47 | class ProcessDiagnosticsError extends Schema.TaggedErrorClass<ProcessDiagnosticsError>()( | 30 | export class ProcessIdentityChanged extends Schema.TaggedErrorClass<ProcessIdentityChanged>()( |
| 48 | "ProcessDiagnosticsError", | 31 | "ProcessIdentityChanged", |
| 49 | { | 32 | { |
| 50 | message: Schema.String, | 33 | pid: Schema.Number, |
| 51 | cause: Schema.optional(Schema.Defect()), | 34 | startTimeMs: Schema.Number, |
| 52 | }, | 35 | }, |
| 53 | ) {} | 36 | ) { |
| 54 | const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); | 37 | override get message(): string { |
| 55 | 38 | return `Process ${this.pid} no longer matches start time ${this.startTimeMs}.`; | |
| 56 | function toProcessDiagnosticsError(message: string, cause?: unknown): ProcessDiagnosticsError { | ||
| 57 | return new ProcessDiagnosticsError({ | ||
| 58 | message, | ||
| 59 | ...(cause === undefined ? {} : { cause }), | ||
| 60 | }); | ||
| 61 | } | ||
| 62 | |||
| 63 | function parsePositiveInt(value: string): number | null { | ||
| 64 | const parsed = Number.parseInt(value, 10); | ||
| 65 | return Number.isInteger(parsed) && parsed > 0 ? parsed : null; | ||
| 66 | } | ||
| 67 | |||
| 68 | function parseNonNegativeInt(value: string): number | null { | ||
| 69 | const parsed = Number.parseInt(value, 10); | ||
| 70 | return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; | ||
| 71 | } | ||
| 72 | |||
| 73 | function parseNumber(value: string): number | null { | ||
| 74 | const parsed = Number.parseFloat(value); | ||
| 75 | return Number.isFinite(parsed) ? parsed : null; | ||
| 76 | } | ||
| 77 | |||
| 78 | export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> { | ||
| 79 | const rows: ProcessRow[] = []; | ||
| 80 | const rowPattern = | ||
| 81 | /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+([+-]?(?:\d+\.?\d*|\.\d+))\s+(\d+)\s+(\S+)\s+(.+)$/; | ||
| 82 | |||
| 83 | for (const line of output.split(/\r?\n/)) { | ||
| 84 | if (line.trim().length === 0) continue; | ||
| 85 | |||
| 86 | const match = rowPattern.exec(line); | ||
| 87 | if (!match) continue; | ||
| 88 | |||
| 89 | const pidText = match[1]; | ||
| 90 | const ppidText = match[2]; | ||
| 91 | const pgidText = match[3]; | ||
| 92 | const status = match[4]; | ||
| 93 | const cpuText = match[5]; | ||
| 94 | const rssText = match[6]; | ||
| 95 | const elapsed = match[7]; | ||
| 96 | const command = match[8]; | ||
| 97 | if ( | ||
| 98 | pidText === undefined || | ||
| 99 | ppidText === undefined || | ||
| 100 | pgidText === undefined || | ||
| 101 | status === undefined || | ||
| 102 | cpuText === undefined || | ||
| 103 | rssText === undefined || | ||
| 104 | elapsed === undefined || | ||
| 105 | command === undefined | ||
| 106 | ) { | ||
| 107 | continue; | ||
| 108 | } | ||
| 109 | |||
| 110 | const pid = parsePositiveInt(pidText); | ||
| 111 | const ppid = parseNonNegativeInt(ppidText); | ||
| 112 | const pgid = Number.parseInt(pgidText, 10); | ||
| 113 | const cpuPercent = parseNumber(cpuText); | ||
| 114 | const rssKiB = parseNonNegativeInt(rssText); | ||
| 115 | if ( | ||
| 116 | pid === null || | ||
| 117 | ppid === null || | ||
| 118 | !Number.isInteger(pgid) || | ||
| 119 | cpuPercent === null || | ||
| 120 | rssKiB === null || | ||
| 121 | !status || | ||
| 122 | !elapsed || | ||
| 123 | !command | ||
| 124 | ) { | ||
| 125 | continue; | ||
| 126 | } | ||
| 127 | |||
| 128 | rows.push({ | ||
| 129 | pid, | ||
| 130 | ppid, | ||
| 131 | pgid, | ||
| 132 | status, | ||
| 133 | cpuPercent, | ||
| 134 | rssBytes: rssKiB * 1024, | ||
| 135 | elapsed, | ||
| 136 | command, | ||
| 137 | }); | ||
| 138 | } | 39 | } |
| 139 | |||
| 140 | return rows; | ||
| 141 | } | ||
| 142 | |||
| 143 | function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { | ||
| 144 | if (typeof value !== "object" || value === null) return null; | ||
| 145 | const record = value as Record<string, unknown>; | ||
| 146 | const pid = typeof record.ProcessId === "number" ? record.ProcessId : null; | ||
| 147 | const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null; | ||
| 148 | const commandLine = | ||
| 149 | typeof record.CommandLine === "string" && record.CommandLine.trim().length > 0 | ||
| 150 | ? record.CommandLine | ||
| 151 | : typeof record.Name === "string" | ||
| 152 | ? record.Name | ||
| 153 | : null; | ||
| 154 | const workingSet = | ||
| 155 | typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize) | ||
| 156 | ? Math.max(0, Math.round(record.WorkingSetSize)) | ||
| 157 | : 0; | ||
| 158 | const cpuPercent = | ||
| 159 | typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime) | ||
| 160 | ? Math.max(0, record.PercentProcessorTime) | ||
| 161 | : 0; | ||
| 162 | |||
| 163 | if (!pid || pid <= 0 || ppid === null || ppid < 0 || !commandLine) return null; | ||
| 164 | return { | ||
| 165 | pid, | ||
| 166 | ppid, | ||
| 167 | pgid: null, | ||
| 168 | status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live", | ||
| 169 | cpuPercent, | ||
| 170 | rssBytes: workingSet, | ||
| 171 | elapsed: "", | ||
| 172 | command: commandLine, | ||
| 173 | }; | ||
| 174 | } | 40 | } |
| 175 | 41 | ||
| 176 | function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> { | 42 | export class ProcessSignalFailed extends Schema.TaggedErrorClass<ProcessSignalFailed>()( |
| 177 | if (output.trim().length === 0) return []; | 43 | "ProcessSignalFailed", |
| 178 | try { | 44 | { |
| 179 | const parsed = JSON.parse(output) as unknown; | 45 | pid: Schema.Number, |
| 180 | const records = Array.isArray(parsed) ? parsed : [parsed]; | 46 | signal: Schema.String, |
| 181 | return records.flatMap((record) => { | 47 | cause: Schema.Defect(), |
| 182 | const row = normalizeWindowsProcessRow(record); | 48 | }, |
| 183 | return row ? [row] : []; | 49 | ) { |
| 184 | }); | 50 | override get message(): string { |
| 185 | } catch { | 51 | return `Failed to signal process ${this.pid} with ${this.signal}.`; |
| 186 | return []; | ||
| 187 | } | 52 | } |
| 188 | } | 53 | } |
| 189 | 54 | ||
| 190 | export function buildDescendantEntries( | 55 | export type ProcessDiagnosticsError = ProcessIdentityChanged | ProcessSignalFailed; |
| 191 | rows: ReadonlyArray<ProcessRow>, | ||
| 192 | serverPid: number, | ||
| 193 | ): ReadonlyArray<ServerProcessDiagnosticsEntry> { | ||
| 194 | const childrenByParent = new Map<number, ProcessRow[]>(); | ||
| 195 | for (const row of rows) { | ||
| 196 | const children = childrenByParent.get(row.ppid) ?? []; | ||
| 197 | children.push(row); | ||
| 198 | childrenByParent.set(row.ppid, children); | ||
| 199 | } | ||
| 200 | |||
| 201 | const entries: ServerProcessDiagnosticsEntry[] = []; | ||
| 202 | const visited = new Set<number>(); | ||
| 203 | const stack = [...(childrenByParent.get(serverPid) ?? [])] | ||
| 204 | .toSorted((left, right) => left.pid - right.pid) | ||
| 205 | .map((row) => ({ row, depth: 0 })); | ||
| 206 | |||
| 207 | while (stack.length > 0) { | ||
| 208 | const item = stack.shift(); | ||
| 209 | if (!item || visited.has(item.row.pid)) continue; | ||
| 210 | visited.add(item.row.pid); | ||
| 211 | |||
| 212 | const children = [...(childrenByParent.get(item.row.pid) ?? [])].toSorted( | ||
| 213 | (left, right) => left.pid - right.pid, | ||
| 214 | ); | ||
| 215 | entries.push({ | ||
| 216 | pid: item.row.pid, | ||
| 217 | ppid: item.row.ppid, | ||
| 218 | pgid: Option.fromNullishOr(item.row.pgid), | ||
| 219 | status: item.row.status, | ||
| 220 | cpuPercent: item.row.cpuPercent, | ||
| 221 | rssBytes: item.row.rssBytes, | ||
| 222 | elapsed: item.row.elapsed || "n/a", | ||
| 223 | command: item.row.command, | ||
| 224 | depth: item.depth, | ||
| 225 | childPids: children.map((child) => child.pid), | ||
| 226 | }); | ||
| 227 | |||
| 228 | stack.unshift(...children.map((row) => ({ row, depth: item.depth + 1 }))); | ||
| 229 | } | ||
| 230 | 56 | ||
| 231 | return entries; | 57 | function formatElapsed(runTimeMs: number): string { |
| 58 | const totalSeconds = Math.max(0, Math.floor(runTimeMs / 1_000)); | ||
| 59 | const hours = Math.floor(totalSeconds / 3_600); | ||
| 60 | const minutes = Math.floor((totalSeconds % 3_600) / 60); | ||
| 61 | const seconds = totalSeconds % 60; | ||
| 62 | return hours > 0 | ||
| 63 | ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` | ||
| 64 | : `${minutes}:${String(seconds).padStart(2, "0")}`; | ||
| 232 | } | 65 | } |
| 233 | 66 | ||
| 234 | export function isDiagnosticsQueryProcess(row: ProcessRow, serverPid: number): boolean { | 67 | function canSignalCategory(category: ResourceTelemetryProcessCategory): boolean { |
| 235 | if (row.ppid !== serverPid) return false; | ||
| 236 | |||
| 237 | const command = row.command.trim(); | ||
| 238 | return ( | 68 | return ( |
| 239 | /(?:^|[/\\])ps\s+-axo\s+pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=/.test(command) || | 69 | category === "server-child" || category === "provider-root" || category === "terminal-root" |
| 240 | (/\bpowershell(?:\.exe)?\b/i.test(command) && | ||
| 241 | /\bGet-CimInstance\s+Win32_Process\b/i.test(command)) | ||
| 242 | ); | ||
| 243 | } | ||
| 244 | |||
| 245 | function makeResult(input: { | ||
| 246 | readonly serverPid: number; | ||
| 247 | readonly rows: ReadonlyArray<ProcessRow>; | ||
| 248 | readonly readAt: DateTime.Utc; | ||
| 249 | readonly error?: string; | ||
| 250 | }): ServerProcessDiagnosticsResult { | ||
| 251 | const readAt = input.readAt; | ||
| 252 | const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid)); | ||
| 253 | const processes = buildDescendantEntries(rows, input.serverPid); | ||
| 254 | const totalRssBytes = processes.reduce((total, process) => total + process.rssBytes, 0); | ||
| 255 | const totalCpuPercent = processes.reduce((total, process) => total + process.cpuPercent, 0); | ||
| 256 | |||
| 257 | return { | ||
| 258 | serverPid: input.serverPid, | ||
| 259 | readAt, | ||
| 260 | processCount: processes.length, | ||
| 261 | totalRssBytes, | ||
| 262 | totalCpuPercent, | ||
| 263 | processes, | ||
| 264 | error: input.error ? Option.some({ message: input.error }) : Option.none(), | ||
| 265 | }; | ||
| 266 | } | ||
| 267 | |||
| 268 | interface ProcessOutput { | ||
| 269 | readonly exitCode: number; | ||
| 270 | readonly stdout: string; | ||
| 271 | readonly stderr: string; | ||
| 272 | } | ||
| 273 | |||
| 274 | const runProcess = Effect.fn("runProcess")( | ||
| 275 | function* (input: { | ||
| 276 | readonly command: string; | ||
| 277 | readonly args: ReadonlyArray<string>; | ||
| 278 | readonly errorMessage: string; | ||
| 279 | }) { | ||
| 280 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 281 | // `ps` and `powershell.exe` are real executables; spawning through cmd.exe | ||
| 282 | // shell mode would re-tokenize the PowerShell `-Command` payload (which | ||
| 283 | // contains pipes) before PowerShell ever sees it. | ||
| 284 | const child = yield* spawner.spawn( | ||
| 285 | ChildProcess.make(input.command, input.args, { | ||
| 286 | cwd: process.cwd(), | ||
| 287 | }), | ||
| 288 | ); | ||
| 289 | const [stdout, stderr, exitCode] = yield* Effect.all( | ||
| 290 | [ | ||
| 291 | collectUint8StreamText({ | ||
| 292 | stream: child.stdout, | ||
| 293 | maxBytes: PROCESS_QUERY_MAX_OUTPUT_BYTES, | ||
| 294 | truncatedMarker: "\n\n[truncated]", | ||
| 295 | }), | ||
| 296 | collectUint8StreamText({ | ||
| 297 | stream: child.stderr, | ||
| 298 | maxBytes: PROCESS_QUERY_MAX_OUTPUT_BYTES, | ||
| 299 | truncatedMarker: "\n\n[truncated]", | ||
| 300 | }), | ||
| 301 | child.exitCode, | ||
| 302 | ], | ||
| 303 | { concurrency: "unbounded" }, | ||
| 304 | ); | ||
| 305 | |||
| 306 | return { | ||
| 307 | exitCode, | ||
| 308 | stdout: stdout.text, | ||
| 309 | stderr: stderr.text, | ||
| 310 | } satisfies ProcessOutput; | ||
| 311 | }, | ||
| 312 | (effect, input) => | ||
| 313 | effect.pipe( | ||
| 314 | Effect.scoped, | ||
| 315 | Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), | ||
| 316 | Effect.flatMap((result) => | ||
| 317 | Option.match(result, { | ||
| 318 | onNone: () => Effect.fail(toProcessDiagnosticsError(`${input.errorMessage} timed out.`)), | ||
| 319 | onSome: Effect.succeed, | ||
| 320 | }), | ||
| 321 | ), | ||
| 322 | Effect.mapError((cause) => | ||
| 323 | isProcessDiagnosticsError(cause) | ||
| 324 | ? cause | ||
| 325 | : toProcessDiagnosticsError(input.errorMessage, cause), | ||
| 326 | ), | ||
| 327 | ), | ||
| 328 | ); | ||
| 329 | |||
| 330 | function readPosixProcessRows(): Effect.Effect< | ||
| 331 | ReadonlyArray<ProcessRow>, | ||
| 332 | ProcessDiagnosticsError, | ||
| 333 | ChildProcessSpawner.ChildProcessSpawner | ||
| 334 | > { | ||
| 335 | return runProcess({ | ||
| 336 | command: "ps", | ||
| 337 | args: ["-axo", POSIX_PROCESS_QUERY_COMMAND], | ||
| 338 | errorMessage: "Failed to query process diagnostics.", | ||
| 339 | }).pipe( | ||
| 340 | Effect.flatMap((result) => | ||
| 341 | result.exitCode !== 0 | ||
| 342 | ? Effect.fail(toProcessDiagnosticsError(result.stderr.trim() || "ps failed.")) | ||
| 343 | : Effect.succeed(parsePosixProcessRows(result.stdout)), | ||
| 344 | ), | ||
| 345 | ); | ||
| 346 | } | ||
| 347 | |||
| 348 | function readWindowsProcessRows(): Effect.Effect< | ||
| 349 | ReadonlyArray<ProcessRow>, | ||
| 350 | ProcessDiagnosticsError, | ||
| 351 | ChildProcessSpawner.ChildProcessSpawner | ||
| 352 | > { | ||
| 353 | const command = [ | ||
| 354 | "$processes = Get-CimInstance Win32_Process | ForEach-Object {", | ||
| 355 | '$perf = Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -Filter "IDProcess = $($_.ProcessId)" -ErrorAction SilentlyContinue;', | ||
| 356 | "[pscustomobject]@{ ProcessId = $_.ProcessId; ParentProcessId = $_.ParentProcessId; Name = $_.Name; CommandLine = $_.CommandLine; Status = $_.Status; WorkingSetSize = $_.WorkingSetSize; PercentProcessorTime = if ($perf) { $perf.PercentProcessorTime } else { 0 } }", | ||
| 357 | "};", | ||
| 358 | "$processes | ConvertTo-Json -Compress -Depth 3", | ||
| 359 | ].join(" "); | ||
| 360 | |||
| 361 | return runProcess({ | ||
| 362 | command: "powershell.exe", | ||
| 363 | args: ["-NoProfile", "-NonInteractive", "-Command", command], | ||
| 364 | errorMessage: "Failed to query process diagnostics.", | ||
| 365 | }).pipe( | ||
| 366 | Effect.flatMap((result) => | ||
| 367 | result.exitCode !== 0 | ||
| 368 | ? Effect.fail( | ||
| 369 | toProcessDiagnosticsError(result.stderr.trim() || "PowerShell process query failed."), | ||
| 370 | ) | ||
| 371 | : Effect.succeed(parseWindowsProcessRows(result.stdout)), | ||
| 372 | ), | ||
| 373 | ); | ||
| 374 | } | ||
| 375 | |||
| 376 | export const readProcessRows = Effect.gen(function* () { | ||
| 377 | const platform = yield* HostProcessPlatform; | ||
| 378 | return yield* platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); | ||
| 379 | }); | ||
| 380 | |||
| 381 | export function aggregateProcessDiagnostics(input: { | ||
| 382 | readonly serverPid: number; | ||
| 383 | readonly rows: ReadonlyArray<ProcessRow>; | ||
| 384 | readonly readAt: DateTime.Utc; | ||
| 385 | }): ServerProcessDiagnosticsResult { | ||
| 386 | return makeResult(input); | ||
| 387 | } | ||
| 388 | |||
| 389 | function assertDescendantPid( | ||
| 390 | pid: number, | ||
| 391 | ): Effect.Effect<void, ProcessDiagnosticsError, ChildProcessSpawner.ChildProcessSpawner> { | ||
| 392 | if (pid === process.pid) { | ||
| 393 | return Effect.fail(toProcessDiagnosticsError("Refusing to signal the T3 server process.")); | ||
| 394 | } | ||
| 395 | |||
| 396 | return readProcessRows.pipe( | ||
| 397 | Effect.flatMap((rows) => { | ||
| 398 | const filteredRows = rows.filter((row) => !isDiagnosticsQueryProcess(row, process.pid)); | ||
| 399 | const descendant = buildDescendantEntries(filteredRows, process.pid).some( | ||
| 400 | (entry) => entry.pid === pid, | ||
| 401 | ); | ||
| 402 | return descendant | ||
| 403 | ? Effect.void | ||
| 404 | : Effect.fail( | ||
| 405 | toProcessDiagnosticsError(`Process ${pid} is not a live descendant of the T3 server.`), | ||
| 406 | ); | ||
| 407 | }), | ||
| 408 | ); | 70 | ); |
| 409 | } | 71 | } |
| 410 | 72 | ||
| 411 | export const make = Effect.fn("makeProcessDiagnostics")(function* () { | 73 | export const make = Effect.fn("makeProcessDiagnostics")(function* () { |
| 412 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 74 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; |
| 413 | 75 | const read: ProcessDiagnosticsShape["read"] = telemetry.latest.pipe( | |
| 414 | const read: ProcessDiagnosticsShape["read"] = Effect.gen(function* () { | 76 | Effect.map((snapshot) => { |
| 415 | const readAt = yield* DateTime.now; | 77 | const processes = snapshot.processes |
| 416 | const rows = yield* readProcessRows.pipe( | 78 | .filter((entry) => entry.identity.pid !== process.pid) |
| 417 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 79 | .map( |
| 418 | ); | 80 | (entry): ServerProcessDiagnosticsEntry => ({ |
| 419 | return makeResult({ serverPid: process.pid, rows, readAt }); | 81 | pid: entry.identity.pid, |
| 420 | }).pipe( | 82 | startTimeMs: entry.identity.startTimeMs, |
| 421 | Effect.catch((error: ProcessDiagnosticsError) => | 83 | ppid: entry.ppid, |
| 422 | DateTime.now.pipe( | 84 | pgid: Option.none(), |
| 423 | Effect.map((readAt) => | 85 | status: entry.status || "Unknown", |
| 424 | makeResult({ serverPid: process.pid, rows: [], readAt, error: error.message }), | 86 | cpuPercent: entry.cpuPercent, |
| 425 | ), | 87 | rssBytes: entry.residentBytes, |
| 426 | ), | 88 | elapsed: formatElapsed(entry.runTimeMs), |
| 427 | ), | 89 | command: entry.command || entry.name || "unknown", |
| 90 | depth: Math.max(0, entry.depth - 1), | ||
| 91 | childPids: entry.childPids, | ||
| 92 | }), | ||
| 93 | ); | ||
| 94 | return { | ||
| 95 | serverPid: process.pid, | ||
| 96 | readAt: snapshot.readAt, | ||
| 97 | processCount: processes.length, | ||
| 98 | totalRssBytes: processes.reduce((total, entry) => total + entry.rssBytes, 0), | ||
| 99 | totalCpuPercent: processes.reduce((total, entry) => total + entry.cpuPercent, 0), | ||
| 100 | processes, | ||
| 101 | error: Option.map(snapshot.health.native.lastError, (message) => ({ message })), | ||
| 102 | }; | ||
| 103 | }), | ||
| 428 | ); | 104 | ); |
| 429 | 105 | ||
| 430 | const signal: ProcessDiagnosticsShape["signal"] = Effect.fn("ProcessDiagnostics.signal")( | 106 | const signal: ProcessDiagnosticsShape["signal"] = Effect.fn("ProcessDiagnostics.signal")( |
| 431 | function* (input) { | 107 | function* (input) { |
| 432 | return yield* assertDescendantPid(input.pid).pipe( | 108 | if (input.pid === process.pid) { |
| 433 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 109 | return { |
| 434 | Effect.flatMap(() => | 110 | pid: input.pid, |
| 435 | Effect.try({ | 111 | signal: input.signal, |
| 436 | try: () => { | 112 | signaled: false, |
| 437 | process.kill(input.pid, input.signal); | 113 | message: Option.some("Refusing to signal the T3 server process."), |
| 438 | return { | 114 | }; |
| 439 | pid: input.pid, | 115 | } |
| 440 | signal: input.signal, | 116 | const current = yield* telemetry.latest; |
| 441 | signaled: true, | 117 | const selected = current.processes.find( |
| 442 | message: Option.none(), | 118 | (entry) => |
| 443 | }; | 119 | entry.identity.pid === input.pid && entry.identity.startTimeMs === input.startTimeMs, |
| 444 | }, | ||
| 445 | catch: (cause) => | ||
| 446 | toProcessDiagnosticsError( | ||
| 447 | `Failed to signal process ${input.pid} with ${input.signal}.`, | ||
| 448 | cause, | ||
| 449 | ), | ||
| 450 | }), | ||
| 451 | ), | ||
| 452 | Effect.catch((error: ProcessDiagnosticsError) => | ||
| 453 | Effect.succeed({ | ||
| 454 | pid: input.pid, | ||
| 455 | signal: input.signal, | ||
| 456 | signaled: false, | ||
| 457 | message: Option.some(error.message), | ||
| 458 | }), | ||
| 459 | ), | ||
| 460 | ); | 120 | ); |
| 121 | if (!selected) { | ||
| 122 | return { | ||
| 123 | pid: input.pid, | ||
| 124 | signal: input.signal, | ||
| 125 | signaled: false, | ||
| 126 | message: Option.some( | ||
| 127 | `Process ${input.pid} no longer matches the selected process identity.`, | ||
| 128 | ), | ||
| 129 | }; | ||
| 130 | } | ||
| 131 | if (!canSignalCategory(selected.category)) { | ||
| 132 | return { | ||
| 133 | pid: input.pid, | ||
| 134 | signal: input.signal, | ||
| 135 | signaled: false, | ||
| 136 | message: Option.some(`Process ${input.pid} is not a signalable T3 backend descendant.`), | ||
| 137 | }; | ||
| 138 | } | ||
| 139 | return yield* telemetry | ||
| 140 | .validateProcessIdentity({ | ||
| 141 | pid: input.pid, | ||
| 142 | startTimeMs: input.startTimeMs, | ||
| 143 | }) | ||
| 144 | .pipe( | ||
| 145 | Effect.flatMap((valid) => | ||
| 146 | valid | ||
| 147 | ? Effect.void | ||
| 148 | : Effect.fail( | ||
| 149 | new ProcessIdentityChanged({ | ||
| 150 | pid: input.pid, | ||
| 151 | startTimeMs: input.startTimeMs, | ||
| 152 | }), | ||
| 153 | ), | ||
| 154 | ), | ||
| 155 | Effect.flatMap(() => | ||
| 156 | Effect.try({ | ||
| 157 | try: () => { | ||
| 158 | process.kill(input.pid, input.signal); | ||
| 159 | return { | ||
| 160 | pid: input.pid, | ||
| 161 | signal: input.signal, | ||
| 162 | signaled: true, | ||
| 163 | message: Option.none(), | ||
| 164 | }; | ||
| 165 | }, | ||
| 166 | catch: (cause) => | ||
| 167 | new ProcessSignalFailed({ | ||
| 168 | pid: input.pid, | ||
| 169 | signal: input.signal, | ||
| 170 | cause, | ||
| 171 | }), | ||
| 172 | }), | ||
| 173 | ), | ||
| 174 | Effect.catch((error) => | ||
| 175 | Effect.succeed({ | ||
| 176 | pid: input.pid, | ||
| 177 | signal: input.signal, | ||
| 178 | signaled: false, | ||
| 179 | message: Option.some( | ||
| 180 | error instanceof Error ? error.message : "Failed to signal process.", | ||
| 181 | ), | ||
| 182 | }), | ||
| 183 | ), | ||
| 184 | ); | ||
| 461 | }, | 185 | }, |
| 462 | ); | 186 | ); |
| 463 | 187 | ||
| @@ -1,231 +1,132 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import type { ResourceTelemetryHistory } from "@t3tools/contracts"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | 3 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 7 | import * as Stream from "effect/Stream"; | ||
| 5 | 8 | ||
| 6 | import { | 9 | import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; |
| 7 | aggregateProcessResourceHistory, | 10 | import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts"; |
| 8 | collectMonitoredSamples, | ||
| 9 | } from "./ProcessResourceMonitor.ts"; | ||
| 10 | 11 | ||
| 11 | describe("ProcessResourceMonitor", () => { | 12 | describe("ProcessResourceMonitor", () => { |
| 12 | it.effect("samples the server root process and descendants", () => | 13 | it.effect("projects resource telemetry history into the legacy diagnostics contract", () => |
| 13 | Effect.sync(() => { | 14 | Effect.gen(function* () { |
| 14 | const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); | 15 | const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); |
| 15 | const samples = collectMonitoredSamples({ | 16 | const history: ResourceTelemetryHistory = { |
| 16 | serverPid: 100, | 17 | readAt, |
| 17 | sampledAt, | 18 | windowMs: 60_000, |
| 18 | sampledAtMs: DateTime.toEpochMillis(sampledAt), | 19 | bucketMs: 10_000, |
| 19 | rows: [ | 20 | sampleIntervalMs: 1_000, |
| 20 | { | 21 | retainedSampleCount: 2, |
| 21 | pid: 100, | 22 | buckets: [ |
| 22 | ppid: 1, | ||
| 23 | pgid: 100, | ||
| 24 | status: "S", | ||
| 25 | cpuPercent: 2, | ||
| 26 | rssBytes: 1_000, | ||
| 27 | elapsed: "01:00", | ||
| 28 | command: "t3 server", | ||
| 29 | }, | ||
| 30 | { | ||
| 31 | pid: 101, | ||
| 32 | ppid: 100, | ||
| 33 | pgid: 100, | ||
| 34 | status: "S", | ||
| 35 | cpuPercent: 10, | ||
| 36 | rssBytes: 2_000, | ||
| 37 | elapsed: "00:20", | ||
| 38 | command: "codex app-server", | ||
| 39 | }, | ||
| 40 | { | ||
| 41 | pid: 102, | ||
| 42 | ppid: 101, | ||
| 43 | pgid: 100, | ||
| 44 | status: "R", | ||
| 45 | cpuPercent: 50, | ||
| 46 | rssBytes: 3_000, | ||
| 47 | elapsed: "00:05", | ||
| 48 | command: "rg needle", | ||
| 49 | }, | ||
| 50 | { | 23 | { |
| 51 | pid: 200, | 24 | startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"), |
| 52 | ppid: 1, | 25 | endedAt: readAt, |
| 53 | pgid: 200, | 26 | avgCpuPercent: 15, |
| 54 | status: "R", | 27 | maxCpuPercent: 25, |
| 55 | cpuPercent: 99, | 28 | maxRssBytes: 4_096, |
| 56 | rssBytes: 9_000, | 29 | ioReadBytes: 1_024, |
| 57 | elapsed: "00:05", | 30 | ioWriteBytes: 2_048, |
| 58 | command: "unrelated", | 31 | maxProcessCount: 2, |
| 59 | }, | 32 | }, |
| 60 | ], | 33 | ], |
| 61 | }); | 34 | topProcesses: [ |
| 62 | |||
| 63 | expect(samples.map((sample) => sample.pid)).toEqual([100, 101, 102]); | ||
| 64 | expect(samples.map((sample) => sample.depth)).toEqual([0, 1, 2]); | ||
| 65 | expect(samples[0]?.isServerRoot).toBe(true); | ||
| 66 | expect(samples[1]?.isServerRoot).toBe(false); | ||
| 67 | }), | ||
| 68 | ); | ||
| 69 | |||
| 70 | it.effect("rolls samples up by process and CPU time", () => | ||
| 71 | Effect.sync(() => { | ||
| 72 | const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); | ||
| 73 | const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.000Z"); | ||
| 74 | const samples = [ | ||
| 75 | ...collectMonitoredSamples({ | ||
| 76 | serverPid: 100, | ||
| 77 | sampledAt: firstAt, | ||
| 78 | sampledAtMs: DateTime.toEpochMillis(firstAt), | ||
| 79 | rows: [ | ||
| 80 | { | ||
| 81 | pid: 100, | ||
| 82 | ppid: 1, | ||
| 83 | pgid: 100, | ||
| 84 | status: "S", | ||
| 85 | cpuPercent: 10, | ||
| 86 | rssBytes: 1_000, | ||
| 87 | elapsed: "01:00", | ||
| 88 | command: "t3 server", | ||
| 89 | }, | ||
| 90 | ], | ||
| 91 | }), | ||
| 92 | ...collectMonitoredSamples({ | ||
| 93 | serverPid: 100, | ||
| 94 | sampledAt: secondAt, | ||
| 95 | sampledAtMs: DateTime.toEpochMillis(secondAt), | ||
| 96 | rows: [ | ||
| 97 | { | ||
| 98 | pid: 100, | ||
| 99 | ppid: 1, | ||
| 100 | pgid: 100, | ||
| 101 | status: "S", | ||
| 102 | cpuPercent: 30, | ||
| 103 | rssBytes: 2_000, | ||
| 104 | elapsed: "01:05", | ||
| 105 | command: "t3 server", | ||
| 106 | }, | ||
| 107 | ], | ||
| 108 | }), | ||
| 109 | ]; | ||
| 110 | |||
| 111 | const result = aggregateProcessResourceHistory({ | ||
| 112 | samples, | ||
| 113 | readAt: secondAt, | ||
| 114 | readAtMs: DateTime.toEpochMillis(secondAt), | ||
| 115 | windowMs: 60_000, | ||
| 116 | bucketMs: 10_000, | ||
| 117 | lastError: null, | ||
| 118 | }); | ||
| 119 | |||
| 120 | expect(Option.isNone(result.error)).toBe(true); | ||
| 121 | expect(result.topProcesses).toHaveLength(1); | ||
| 122 | expect(result.topProcesses[0]?.avgCpuPercent).toBe(20); | ||
| 123 | expect(result.topProcesses[0]?.maxCpuPercent).toBe(30); | ||
| 124 | expect(result.topProcesses[0]?.cpuSecondsApprox).toBe(2); | ||
| 125 | expect(result.totalCpuSecondsApprox).toBe(2); | ||
| 126 | expect(result.buckets.some((bucket) => bucket.maxCpuPercent === 30)).toBe(true); | ||
| 127 | }), | ||
| 128 | ); | ||
| 129 | |||
| 130 | it.effect("keeps a process grouped when elapsed time drifts between samples", () => | ||
| 131 | Effect.sync(() => { | ||
| 132 | const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.400Z"); | ||
| 133 | const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.900Z"); | ||
| 134 | const samples = [ | ||
| 135 | ...collectMonitoredSamples({ | ||
| 136 | serverPid: 100, | ||
| 137 | sampledAt: firstAt, | ||
| 138 | sampledAtMs: DateTime.toEpochMillis(firstAt), | ||
| 139 | rows: [ | ||
| 140 | { | ||
| 141 | pid: 100, | ||
| 142 | ppid: 1, | ||
| 143 | pgid: 100, | ||
| 144 | status: "S", | ||
| 145 | cpuPercent: 1, | ||
| 146 | rssBytes: 1_000, | ||
| 147 | elapsed: "01:00", | ||
| 148 | command: "t3 server", | ||
| 149 | }, | ||
| 150 | ], | ||
| 151 | }), | ||
| 152 | ...collectMonitoredSamples({ | ||
| 153 | serverPid: 100, | ||
| 154 | sampledAt: secondAt, | ||
| 155 | sampledAtMs: DateTime.toEpochMillis(secondAt), | ||
| 156 | rows: [ | ||
| 157 | { | ||
| 158 | pid: 100, | ||
| 159 | ppid: 1, | ||
| 160 | pgid: 100, | ||
| 161 | status: "S", | ||
| 162 | cpuPercent: 2, | ||
| 163 | rssBytes: 2_000, | ||
| 164 | elapsed: "01:06", | ||
| 165 | command: "t3 server", | ||
| 166 | }, | ||
| 167 | ], | ||
| 168 | }), | ||
| 169 | ]; | ||
| 170 | |||
| 171 | const result = aggregateProcessResourceHistory({ | ||
| 172 | samples, | ||
| 173 | readAt: secondAt, | ||
| 174 | readAtMs: DateTime.toEpochMillis(secondAt), | ||
| 175 | windowMs: 60_000, | ||
| 176 | bucketMs: 10_000, | ||
| 177 | lastError: null, | ||
| 178 | }); | ||
| 179 | |||
| 180 | expect(result.topProcesses).toHaveLength(1); | ||
| 181 | expect(result.topProcesses[0]?.isServerRoot).toBe(true); | ||
| 182 | expect(result.topProcesses[0]?.sampleCount).toBe(2); | ||
| 183 | expect(result.topProcesses[0]?.maxRssBytes).toBe(2_000); | ||
| 184 | }), | ||
| 185 | ); | ||
| 186 | |||
| 187 | it.effect("returns all process summaries in the selected window", () => | ||
| 188 | Effect.sync(() => { | ||
| 189 | const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); | ||
| 190 | const samples = collectMonitoredSamples({ | ||
| 191 | serverPid: 100, | ||
| 192 | sampledAt, | ||
| 193 | sampledAtMs: DateTime.toEpochMillis(sampledAt), | ||
| 194 | rows: [ | ||
| 195 | { | 35 | { |
| 196 | pid: 100, | 36 | identity: { pid: process.pid, startTimeMs: 100 }, |
| 197 | ppid: 1, | 37 | ppid: 1, |
| 198 | pgid: 100, | 38 | depth: 0, |
| 199 | status: "S", | 39 | name: "node", |
| 200 | cpuPercent: 1, | ||
| 201 | rssBytes: 1_000, | ||
| 202 | elapsed: "01:00", | ||
| 203 | command: "t3 server", | 40 | command: "t3 server", |
| 41 | category: "server", | ||
| 42 | firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"), | ||
| 43 | lastSeenAt: readAt, | ||
| 44 | currentCpuPercent: 5, | ||
| 45 | avgCpuPercent: 4, | ||
| 46 | maxCpuPercent: 8, | ||
| 47 | cpuTimeMs: 1_500, | ||
| 48 | currentRssBytes: 2_048, | ||
| 49 | peakRssBytes: 4_096, | ||
| 50 | ioReadBytes: 1_024, | ||
| 51 | ioWriteBytes: 2_048, | ||
| 52 | ioSemantics: "storage", | ||
| 53 | sampleCount: 2, | ||
| 204 | }, | 54 | }, |
| 205 | ...Array.from({ length: 35 }, (_, index) => ({ | ||
| 206 | pid: 200 + index, | ||
| 207 | ppid: index === 0 ? 100 : 199 + index, | ||
| 208 | pgid: 100, | ||
| 209 | status: "S", | ||
| 210 | cpuPercent: 35 - index, | ||
| 211 | rssBytes: 2_000 + index, | ||
| 212 | elapsed: "00:10", | ||
| 213 | command: `worker ${index}`, | ||
| 214 | })), | ||
| 215 | ], | 55 | ], |
| 216 | }); | 56 | health: { |
| 57 | native: { | ||
| 58 | status: "degraded", | ||
| 59 | lastSampleAt: Option.some(readAt), | ||
| 60 | lastError: Option.some("collector stalled"), | ||
| 61 | }, | ||
| 62 | desktop: { | ||
| 63 | status: "healthy", | ||
| 64 | lastSampleAt: Option.some(readAt), | ||
| 65 | lastError: Option.none(), | ||
| 66 | }, | ||
| 67 | sidecarVersion: Option.some("0.1.0"), | ||
| 68 | sidecarPid: Option.some(9_000), | ||
| 69 | restartCount: 1, | ||
| 70 | collectionDurationMicros: 250, | ||
| 71 | scannedProcessCount: 80, | ||
| 72 | retainedProcessCount: 2, | ||
| 73 | inaccessibleProcessCount: 0, | ||
| 74 | }, | ||
| 75 | }; | ||
| 76 | const telemetry: ResourceTelemetry.ResourceTelemetryShape = { | ||
| 77 | latest: Effect.die("unused"), | ||
| 78 | changes: Stream.empty, | ||
| 79 | readHistory: () => Effect.succeed(history), | ||
| 80 | refresh: Effect.die("unused"), | ||
| 81 | validateProcessIdentity: () => Effect.die("unused"), | ||
| 82 | retry: Effect.die("unused"), | ||
| 83 | }; | ||
| 84 | const layer = ProcessResourceMonitor.layer.pipe( | ||
| 85 | Layer.provide( | ||
| 86 | Layer.succeed( | ||
| 87 | ResourceTelemetry.ResourceTelemetry, | ||
| 88 | ResourceTelemetry.ResourceTelemetry.of(telemetry), | ||
| 89 | ), | ||
| 90 | ), | ||
| 91 | ); | ||
| 217 | 92 | ||
| 218 | const result = aggregateProcessResourceHistory({ | 93 | const result = yield* Effect.service(ProcessResourceMonitor.ProcessResourceMonitor).pipe( |
| 219 | samples, | 94 | Effect.flatMap((monitor) => |
| 220 | readAt: sampledAt, | 95 | monitor.readHistory({ |
| 221 | readAtMs: DateTime.toEpochMillis(sampledAt), | 96 | windowMs: 60_000, |
| 222 | windowMs: 60_000, | 97 | bucketMs: 10_000, |
| 223 | bucketMs: 10_000, | 98 | }), |
| 224 | lastError: null, | 99 | ), |
| 225 | }); | 100 | Effect.provide(layer), |
| 101 | ); | ||
| 226 | 102 | ||
| 227 | expect(result.topProcesses).toHaveLength(36); | 103 | expect(result.totalCpuSecondsApprox).toBe(1.5); |
| 228 | expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true); | 104 | expect(result.topProcesses).toEqual([ |
| 105 | { | ||
| 106 | processKey: `${process.pid}:100`, | ||
| 107 | pid: process.pid, | ||
| 108 | ppid: 1, | ||
| 109 | command: "t3 server", | ||
| 110 | depth: 0, | ||
| 111 | isServerRoot: true, | ||
| 112 | firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"), | ||
| 113 | lastSeenAt: readAt, | ||
| 114 | currentCpuPercent: 5, | ||
| 115 | avgCpuPercent: 4, | ||
| 116 | maxCpuPercent: 8, | ||
| 117 | cpuSecondsApprox: 1.5, | ||
| 118 | currentRssBytes: 2_048, | ||
| 119 | maxRssBytes: 4_096, | ||
| 120 | sampleCount: 2, | ||
| 121 | }, | ||
| 122 | ]); | ||
| 123 | expect(result.buckets[0]).toMatchObject({ | ||
| 124 | avgCpuPercent: 15, | ||
| 125 | maxCpuPercent: 25, | ||
| 126 | maxRssBytes: 4_096, | ||
| 127 | maxProcessCount: 2, | ||
| 128 | }); | ||
| 129 | expect(result.error).toEqual(Option.some({ message: "collector stalled" })); | ||
| 229 | }), | 130 | }), |
| 230 | ); | 131 | ); |
| 231 | }); | 132 | }); |
| @@ -1,45 +1,13 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | ServerProcessResourceHistoryBucket, | ||
| 3 | ServerProcessResourceHistoryInput, | 2 | ServerProcessResourceHistoryInput, |
| 4 | ServerProcessResourceHistoryResult, | 3 | ServerProcessResourceHistoryResult, |
| 5 | ServerProcessResourceHistorySummary, | ||
| 6 | } from "@t3tools/contracts"; | 4 | } from "@t3tools/contracts"; |
| 7 | import * as Context from "effect/Context"; | 5 | import * as Context from "effect/Context"; |
| 8 | import * as DateTime from "effect/DateTime"; | ||
| 9 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 10 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| 11 | import * as Option from "effect/Option"; | 8 | import * as Option from "effect/Option"; |
| 12 | import * as Ref from "effect/Ref"; | ||
| 13 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 14 | 9 | ||
| 15 | import { | 10 | import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; |
| 16 | buildDescendantEntries, | ||
| 17 | isDiagnosticsQueryProcess, | ||
| 18 | type ProcessRow, | ||
| 19 | readProcessRows, | ||
| 20 | } from "./ProcessDiagnostics.ts"; | ||
| 21 | |||
| 22 | const SAMPLE_INTERVAL_MS = 5_000; | ||
| 23 | const RETENTION_MS = 60 * 60_000; | ||
| 24 | const MAX_RETAINED_SAMPLES = 20_000; | ||
| 25 | |||
| 26 | export interface ProcessResourceSample { | ||
| 27 | readonly sampledAt: DateTime.Utc; | ||
| 28 | readonly sampledAtMs: number; | ||
| 29 | readonly processKey: string; | ||
| 30 | readonly pid: number; | ||
| 31 | readonly ppid: number; | ||
| 32 | readonly command: string; | ||
| 33 | readonly cpuPercent: number; | ||
| 34 | readonly rssBytes: number; | ||
| 35 | readonly depth: number; | ||
| 36 | readonly isServerRoot: boolean; | ||
| 37 | } | ||
| 38 | |||
| 39 | interface MonitorState { | ||
| 40 | readonly samples: ReadonlyArray<ProcessResourceSample>; | ||
| 41 | readonly lastError: string | null; | ||
| 42 | } | ||
| 43 | 11 | ||
| 44 | export interface ProcessResourceMonitorShape { | 12 | export interface ProcessResourceMonitorShape { |
| 45 | readonly readHistory: ( | 13 | readonly readHistory: ( |
| @@ -52,246 +20,51 @@ export class ProcessResourceMonitor extends Context.Service< · 6 unmodified lines | |||
| 52 | ProcessResourceMonitorShape | 20 | ProcessResourceMonitorShape |
| 53 | >()("t3/diagnostics/ProcessResourceMonitor") {} | 21 | >()("t3/diagnostics/ProcessResourceMonitor") {} |
| 54 | 22 | ||
| 55 | function dateTimeFromMillis(ms: number): DateTime.Utc { | ||
| 56 | return DateTime.makeUnsafe(ms); | ||
| 57 | } | ||
| 58 | |||
| 59 | function sampleKey(row: Pick<ProcessRow, "pid" | "command">): string { | ||
| 60 | return `${row.pid}:${row.command}`; | ||
| 61 | } | ||
| 62 | |||
| 63 | function findServerRootRow(rows: ReadonlyArray<ProcessRow>, serverPid: number): ProcessRow | null { | ||
| 64 | return rows.find((row) => row.pid === serverPid) ?? null; | ||
| 65 | } | ||
| 66 | |||
| 67 | export function collectMonitoredSamples(input: { | ||
| 68 | readonly rows: ReadonlyArray<ProcessRow>; | ||
| 69 | readonly serverPid: number; | ||
| 70 | readonly sampledAt: DateTime.Utc; | ||
| 71 | readonly sampledAtMs: number; | ||
| 72 | }): ReadonlyArray<ProcessResourceSample> { | ||
| 73 | const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid)); | ||
| 74 | const root = findServerRootRow(rows, input.serverPid); | ||
| 75 | const descendants = buildDescendantEntries(rows, input.serverPid); | ||
| 76 | const samples: ProcessResourceSample[] = []; | ||
| 77 | |||
| 78 | if (root) { | ||
| 79 | samples.push({ | ||
| 80 | sampledAt: input.sampledAt, | ||
| 81 | sampledAtMs: input.sampledAtMs, | ||
| 82 | processKey: sampleKey(root), | ||
| 83 | pid: root.pid, | ||
| 84 | ppid: root.ppid, | ||
| 85 | command: root.command, | ||
| 86 | cpuPercent: root.cpuPercent, | ||
| 87 | rssBytes: root.rssBytes, | ||
| 88 | depth: 0, | ||
| 89 | isServerRoot: true, | ||
| 90 | }); | ||
| 91 | } | ||
| 92 | |||
| 93 | for (const process of descendants) { | ||
| 94 | samples.push({ | ||
| 95 | sampledAt: input.sampledAt, | ||
| 96 | sampledAtMs: input.sampledAtMs, | ||
| 97 | processKey: sampleKey(process), | ||
| 98 | pid: process.pid, | ||
| 99 | ppid: process.ppid, | ||
| 100 | command: process.command, | ||
| 101 | cpuPercent: process.cpuPercent, | ||
| 102 | rssBytes: process.rssBytes, | ||
| 103 | depth: process.depth + 1, | ||
| 104 | isServerRoot: false, | ||
| 105 | }); | ||
| 106 | } | ||
| 107 | |||
| 108 | return samples; | ||
| 109 | } | ||
| 110 | |||
| 111 | function trimSamples( | ||
| 112 | samples: ReadonlyArray<ProcessResourceSample>, | ||
| 113 | nowMs: number, | ||
| 114 | ): ReadonlyArray<ProcessResourceSample> { | ||
| 115 | const minSampledAtMs = nowMs - RETENTION_MS; | ||
| 116 | const retained = samples.filter((sample) => sample.sampledAtMs >= minSampledAtMs); | ||
| 117 | return retained.length <= MAX_RETAINED_SAMPLES | ||
| 118 | ? retained | ||
| 119 | : retained.slice(retained.length - MAX_RETAINED_SAMPLES); | ||
| 120 | } | ||
| 121 | |||
| 122 | function summarizeProcesses( | ||
| 123 | samples: ReadonlyArray<ProcessResourceSample>, | ||
| 124 | ): ReadonlyArray<ServerProcessResourceHistorySummary> { | ||
| 125 | const groups = new Map<string, ProcessResourceSample[]>(); | ||
| 126 | for (const sample of samples) { | ||
| 127 | const processSamples = groups.get(sample.processKey) ?? []; | ||
| 128 | processSamples.push(sample); | ||
| 129 | groups.set(sample.processKey, processSamples); | ||
| 130 | } | ||
| 131 | |||
| 132 | return [...groups.entries()] | ||
| 133 | .map(([processKey, processSamples]) => { | ||
| 134 | const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs); | ||
| 135 | const first = sorted[0]!; | ||
| 136 | const latest = sorted[sorted.length - 1]!; | ||
| 137 | const cpuPercentTotal = sorted.reduce((total, sample) => total + sample.cpuPercent, 0); | ||
| 138 | const maxCpuPercent = Math.max(...sorted.map((sample) => sample.cpuPercent)); | ||
| 139 | const maxRssBytes = Math.max(...sorted.map((sample) => sample.rssBytes)); | ||
| 140 | const cpuSecondsApprox = sorted.reduce( | ||
| 141 | (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000), | ||
| 142 | 0, | ||
| 143 | ); | ||
| 144 | |||
| 145 | return { | ||
| 146 | processKey, | ||
| 147 | pid: latest.pid, | ||
| 148 | ppid: latest.ppid, | ||
| 149 | command: latest.command, | ||
| 150 | depth: latest.depth, | ||
| 151 | isServerRoot: latest.isServerRoot, | ||
| 152 | firstSeenAt: first.sampledAt, | ||
| 153 | lastSeenAt: latest.sampledAt, | ||
| 154 | currentCpuPercent: latest.cpuPercent, | ||
| 155 | avgCpuPercent: cpuPercentTotal / sorted.length, | ||
| 156 | maxCpuPercent, | ||
| 157 | cpuSecondsApprox, | ||
| 158 | currentRssBytes: latest.rssBytes, | ||
| 159 | maxRssBytes, | ||
| 160 | sampleCount: sorted.length, | ||
| 161 | } satisfies ServerProcessResourceHistorySummary; | ||
| 162 | }) | ||
| 163 | .toSorted((left, right) => right.cpuSecondsApprox - left.cpuSecondsApprox); | ||
| 164 | } | ||
| 165 | |||
| 166 | function buildBuckets(input: { | ||
| 167 | readonly samples: ReadonlyArray<ProcessResourceSample>; | ||
| 168 | readonly nowMs: number; | ||
| 169 | readonly windowMs: number; | ||
| 170 | readonly bucketMs: number; | ||
| 171 | }): ReadonlyArray<ServerProcessResourceHistoryBucket> { | ||
| 172 | const bucketMs = Math.max(1_000, input.bucketMs); | ||
| 173 | const windowStartMs = input.nowMs - input.windowMs; | ||
| 174 | const buckets: ServerProcessResourceHistoryBucket[] = []; | ||
| 175 | |||
| 176 | for (let startedAtMs = windowStartMs; startedAtMs < input.nowMs; startedAtMs += bucketMs) { | ||
| 177 | const endedAtMs = Math.min(input.nowMs, startedAtMs + bucketMs); | ||
| 178 | const bucketSamples = input.samples.filter( | ||
| 179 | (sample) => | ||
| 180 | sample.sampledAtMs >= startedAtMs && | ||
| 181 | (endedAtMs === input.nowMs | ||
| 182 | ? sample.sampledAtMs <= endedAtMs | ||
| 183 | : sample.sampledAtMs < endedAtMs), | ||
| 184 | ); | ||
| 185 | const samplesByRead = new Map<number, ProcessResourceSample[]>(); | ||
| 186 | for (const sample of bucketSamples) { | ||
| 187 | const samplesAtTime = samplesByRead.get(sample.sampledAtMs) ?? []; | ||
| 188 | samplesAtTime.push(sample); | ||
| 189 | samplesByRead.set(sample.sampledAtMs, samplesAtTime); | ||
| 190 | } | ||
| 191 | |||
| 192 | const readTotals = [...samplesByRead.values()].map((samplesAtTime) => ({ | ||
| 193 | cpuPercent: samplesAtTime.reduce((total, sample) => total + sample.cpuPercent, 0), | ||
| 194 | rssBytes: samplesAtTime.reduce((total, sample) => total + sample.rssBytes, 0), | ||
| 195 | processCount: samplesAtTime.length, | ||
| 196 | })); | ||
| 197 | const avgCpuPercent = | ||
| 198 | readTotals.length === 0 | ||
| 199 | ? 0 | ||
| 200 | : readTotals.reduce((total, read) => total + read.cpuPercent, 0) / readTotals.length; | ||
| 201 | |||
| 202 | buckets.push({ | ||
| 203 | startedAt: dateTimeFromMillis(startedAtMs), | ||
| 204 | endedAt: dateTimeFromMillis(endedAtMs), | ||
| 205 | avgCpuPercent, | ||
| 206 | maxCpuPercent: readTotals.length ? Math.max(...readTotals.map((read) => read.cpuPercent)) : 0, | ||
| 207 | maxRssBytes: readTotals.length ? Math.max(...readTotals.map((read) => read.rssBytes)) : 0, | ||
| 208 | maxProcessCount: readTotals.length | ||
| 209 | ? Math.max(...readTotals.map((read) => read.processCount)) | ||
| 210 | : 0, | ||
| 211 | }); | ||
| 212 | } | ||
| 213 | |||
| 214 | return buckets; | ||
| 215 | } | ||
| 216 | |||
| 217 | export function aggregateProcessResourceHistory(input: { | ||
| 218 | readonly samples: ReadonlyArray<ProcessResourceSample>; | ||
| 219 | readonly readAt: DateTime.Utc; | ||
| 220 | readonly readAtMs: number; | ||
| 221 | readonly windowMs: number; | ||
| 222 | readonly bucketMs: number; | ||
| 223 | readonly lastError: string | null; | ||
| 224 | }): ServerProcessResourceHistoryResult { | ||
| 225 | const windowMs = Math.max(1_000, input.windowMs); | ||
| 226 | const bucketMs = Math.max(1_000, input.bucketMs); | ||
| 227 | const minSampledAtMs = input.readAtMs - windowMs; | ||
| 228 | const samples = input.samples.filter((sample) => sample.sampledAtMs >= minSampledAtMs); | ||
| 229 | const topProcesses = summarizeProcesses(samples); | ||
| 230 | const totalCpuSecondsApprox = samples.reduce( | ||
| 231 | (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000), | ||
| 232 | 0, | ||
| 233 | ); | ||
| 234 | |||
| 235 | return { | ||
| 236 | readAt: input.readAt, | ||
| 237 | windowMs, | ||
| 238 | bucketMs, | ||
| 239 | sampleIntervalMs: SAMPLE_INTERVAL_MS, | ||
| 240 | retainedSampleCount: input.samples.length, | ||
| 241 | totalCpuSecondsApprox, | ||
| 242 | buckets: buildBuckets({ samples, nowMs: input.readAtMs, windowMs, bucketMs }), | ||
| 243 | topProcesses, | ||
| 244 | error: input.lastError ? Option.some({ message: input.lastError }) : Option.none(), | ||
| 245 | }; | ||
| 246 | } | ||
| 247 | |||
| 248 | export const make = Effect.fn("makeProcessResourceMonitor")(function* () { | 23 | export const make = Effect.fn("makeProcessResourceMonitor")(function* () { |
| 249 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 24 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; |
| 250 | const state = yield* Ref.make<MonitorState>({ samples: [], lastError: null }); | ||
| 251 | |||
| 252 | const sampleOnce = Effect.gen(function* () { | ||
| 253 | const sampledAt = yield* DateTime.now; | ||
| 254 | const sampledAtMs = DateTime.toEpochMillis(sampledAt); | ||
| 255 | const rows = yield* readProcessRows.pipe( | ||
| 256 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 257 | ); | ||
| 258 | const samples = collectMonitoredSamples({ | ||
| 259 | rows, | ||
| 260 | serverPid: process.pid, | ||
| 261 | sampledAt, | ||
| 262 | sampledAtMs, | ||
| 263 | }); | ||
| 264 | yield* Ref.update(state, (current) => ({ | ||
| 265 | samples: trimSamples([...current.samples, ...samples], sampledAtMs), | ||
| 266 | lastError: null, | ||
| 267 | })); | ||
| 268 | }).pipe( | ||
| 269 | Effect.catch((error: unknown) => | ||
| 270 | Ref.update(state, (current) => ({ | ||
| 271 | ...current, | ||
| 272 | lastError: error instanceof Error ? error.message : "Failed to sample process resources.", | ||
| 273 | })), | ||
| 274 | ), | ||
| 275 | ); | ||
| 276 | |||
| 277 | yield* Effect.forever(sampleOnce.pipe(Effect.andThen(Effect.sleep(SAMPLE_INTERVAL_MS)))).pipe( | ||
| 278 | Effect.forkScoped, | ||
| 279 | ); | ||
| 280 | |||
| 281 | const readHistory: ProcessResourceMonitorShape["readHistory"] = (input) => | 25 | const readHistory: ProcessResourceMonitorShape["readHistory"] = (input) => |
| 282 | Effect.gen(function* () { | 26 | telemetry.readHistory(input).pipe( |
| 283 | const readAt = yield* DateTime.now; | 27 | Effect.map((history) => { |
| 284 | const readAtMs = DateTime.toEpochMillis(readAt); | 28 | const topProcesses = history.topProcesses.map((entry) => ({ |
| 285 | const current = yield* Ref.get(state); | 29 | processKey: `${entry.identity.pid}:${entry.identity.startTimeMs}`, |
| 286 | return aggregateProcessResourceHistory({ | 30 | pid: entry.identity.pid, |
| 287 | samples: current.samples, | 31 | ppid: entry.ppid, |
| 288 | readAt, | 32 | command: entry.command || entry.name || "unknown", |
| 289 | readAtMs, | 33 | depth: entry.depth, |
| 290 | windowMs: input.windowMs, | 34 | isServerRoot: entry.category === "server", |
| 291 | bucketMs: input.bucketMs, | 35 | firstSeenAt: entry.firstSeenAt, |
| 292 | lastError: current.lastError, | 36 | lastSeenAt: entry.lastSeenAt, |
| 293 | }); | 37 | currentCpuPercent: entry.currentCpuPercent, |
| 294 | }); | 38 | avgCpuPercent: entry.avgCpuPercent, |
| 39 | maxCpuPercent: entry.maxCpuPercent, | ||
| 40 | cpuSecondsApprox: entry.cpuTimeMs / 1_000, | ||
| 41 | currentRssBytes: entry.currentRssBytes, | ||
| 42 | maxRssBytes: entry.peakRssBytes, | ||
| 43 | sampleCount: entry.sampleCount, | ||
| 44 | })); | ||
| 45 | return { | ||
| 46 | readAt: history.readAt, | ||
| 47 | windowMs: history.windowMs, | ||
| 48 | bucketMs: history.bucketMs, | ||
| 49 | sampleIntervalMs: history.sampleIntervalMs, | ||
| 50 | retainedSampleCount: history.retainedSampleCount, | ||
| 51 | totalCpuSecondsApprox: topProcesses.reduce( | ||
| 52 | (total, entry) => total + entry.cpuSecondsApprox, | ||
| 53 | 0, | ||
| 54 | ), | ||
| 55 | buckets: history.buckets.map((bucket) => ({ | ||
| 56 | startedAt: bucket.startedAt, | ||
| 57 | endedAt: bucket.endedAt, | ||
| 58 | avgCpuPercent: bucket.avgCpuPercent, | ||
| 59 | maxCpuPercent: bucket.maxCpuPercent, | ||
| 60 | maxRssBytes: bucket.maxRssBytes, | ||
| 61 | maxProcessCount: bucket.maxProcessCount, | ||
| 62 | })), | ||
| 63 | topProcesses, | ||
| 64 | error: history.health.native.lastError.pipe(Option.map((message) => ({ message }))), | ||
| 65 | }; | ||
| 66 | }), | ||
| 67 | ); | ||
| 295 | 68 | ||
| 296 | return ProcessResourceMonitor.of({ readHistory }); | 69 | return ProcessResourceMonitor.of({ readHistory }); |
| 297 | }); | 70 | }); |
| @@ -7,6 +7,7 @@ import * as Tracer from "effect/Tracer"; · 6 unmodified lines | |||
| 7 | import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | 7 | import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 8 | 8 | ||
| 9 | import { ServerConfig } from "../../config.ts"; | 9 | import { ServerConfig } from "../../config.ts"; |
| 10 | import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; | ||
| 10 | import { ServerLoggerLive } from "../../serverLogger.ts"; | 11 | import { ServerLoggerLive } from "../../serverLogger.ts"; |
| 11 | import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts"; | 12 | import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts"; |
| 12 | 13 | ||
| @@ -15,6 +16,7 @@ const otlpSerializationLayer = OtlpSerialization.layerJson; · 2 unmodified lines | |||
| 15 | export const ObservabilityLive = Layer.unwrap( | 16 | export const ObservabilityLive = Layer.unwrap( |
| 16 | Effect.gen(function* () { | 17 | Effect.gen(function* () { |
| 17 | const config = yield* ServerConfig; | 18 | const config = yield* ServerConfig; |
| 19 | const attribution = yield* ResourceAttribution.ResourceAttribution; | ||
| 18 | 20 | ||
| 19 | const traceReferencesLayer = Layer.mergeAll( | 21 | const traceReferencesLayer = Layer.mergeAll( |
| 20 | Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), | 22 | Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), |
| @@ -29,6 +31,14 @@ export const ObservabilityLive = Layer.unwrap( · 8 unmodified lines | |||
| 29 | maxBytes: config.traceMaxBytes, | 31 | maxBytes: config.traceMaxBytes, |
| 30 | maxFiles: config.traceMaxFiles, | 32 | maxFiles: config.traceMaxFiles, |
| 31 | batchWindowMs: config.traceBatchWindowMs, | 33 | batchWindowMs: config.traceBatchWindowMs, |
| 34 | onFlush: (stats) => | ||
| 35 | attribution.record({ | ||
| 36 | component: "server-trace", | ||
| 37 | operation: "append", | ||
| 38 | logicalWriteBytes: stats.logicalWriteBytes, | ||
| 39 | count: stats.count, | ||
| 40 | durationMs: stats.durationMs, | ||
| 41 | }), | ||
| 32 | }); | 42 | }); |
| 33 | const delegate = | 43 | const delegate = |
| 34 | config.otlpTracesUrl === undefined | 44 | config.otlpTracesUrl === undefined |
| @@ -7,6 +7,7 @@ import { ThreadId } from "@t3tools/contracts"; · 6 unmodified lines | |||
| 7 | import { assert, describe, it } from "@effect/vitest"; | 7 | import { assert, describe, it } from "@effect/vitest"; |
| 8 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | 9 | ||
| 10 | import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; | ||
| 10 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 11 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 11 | 12 | ||
| 12 | function parseLogLine(line: string) { | 13 | function parseLogLine(line: string) { |
| @@ -207,4 +208,36 @@ describe("EventNdjsonLogger", () => { · 194 unmodified lines | |||
| 207 | } | 208 | } |
| 208 | }), | 209 | }), |
| 209 | ); | 210 | ); |
| 211 | |||
| 212 | it.effect("reports logical provider log writes to resource attribution", () => | ||
| 213 | Effect.gen(function* () { | ||
| 214 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); | ||
| 215 | const basePath = path.join(tempDir, "provider-native.ndjson"); | ||
| 216 | |||
| 217 | try { | ||
| 218 | const attribution = yield* ResourceAttribution.make(); | ||
| 219 | const logger = yield* makeEventNdjsonLogger(basePath, { | ||
| 220 | stream: "native", | ||
| 221 | batchWindowMs: 0, | ||
| 222 | attribution, | ||
| 223 | }); | ||
| 224 | assert.notEqual(logger, undefined); | ||
| 225 | if (!logger) { | ||
| 226 | return; | ||
| 227 | } | ||
| 228 | |||
| 229 | yield* logger.write({ id: "attributed-event" }, ThreadId.make("thread-attribution")); | ||
| 230 | yield* logger.close(); | ||
| 231 | |||
| 232 | const snapshot = yield* attribution.snapshot; | ||
| 233 | assert.equal(snapshot.entries.length, 1); | ||
| 234 | assert.equal(snapshot.entries[0]?.component, "provider-event-log"); | ||
| 235 | assert.equal(snapshot.entries[0]?.operation, "native.append"); | ||
| 236 | assert.equal(snapshot.entries[0]?.count, 1); | ||
| 237 | assert.isAbove(snapshot.entries[0]?.logicalWriteBytes ?? 0, 0); | ||
| 238 | } finally { | ||
| 239 | fs.rmSync(tempDir, { recursive: true, force: true }); | ||
| 240 | } | ||
| 241 | }), | ||
| 242 | ); | ||
| 210 | }); | 243 | }); |
| @@ -11,6 +11,7 @@ import path from "node:path"; · 10 unmodified lines | |||
| 11 | 11 | ||
| 12 | import type { ThreadId } from "@t3tools/contracts"; | 12 | import type { ThreadId } from "@t3tools/contracts"; |
| 13 | import { RotatingFileSink } from "@t3tools/shared/logging"; | 13 | import { RotatingFileSink } from "@t3tools/shared/logging"; |
| 14 | import * as Clock from "effect/Clock"; | ||
| 14 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| 15 | import * as Exit from "effect/Exit"; | 16 | import * as Exit from "effect/Exit"; |
| 16 | import * as Logger from "effect/Logger"; | 17 | import * as Logger from "effect/Logger"; |
| @@ -19,6 +20,7 @@ import * as Scope from "effect/Scope"; · 2 unmodified lines | |||
| 19 | import * as SynchronizedRef from "effect/SynchronizedRef"; | 20 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 20 | 21 | ||
| 21 | import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; | 22 | import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; |
| 23 | import type { ResourceAttributionShape } from "../../resourceTelemetry/ResourceAttribution.ts"; | ||
| 22 | 24 | ||
| 23 | const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; | 25 | const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; |
| 24 | const DEFAULT_MAX_FILES = 10; | 26 | const DEFAULT_MAX_FILES = 10; |
| @@ -26,6 +28,7 @@ const DEFAULT_BATCH_WINDOW_MS = 200; · 1 unmodified line | |||
| 26 | const GLOBAL_THREAD_SEGMENT = "_global"; | 28 | const GLOBAL_THREAD_SEGMENT = "_global"; |
| 27 | const LOG_SCOPE = "provider-observability"; | 29 | const LOG_SCOPE = "provider-observability"; |
| 28 | const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); | 30 | const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); |
| 31 | const textEncoder = new TextEncoder(); | ||
| 29 | 32 | ||
| 30 | export type EventNdjsonStream = "native" | "canonical" | "orchestration"; | 33 | export type EventNdjsonStream = "native" | "canonical" | "orchestration"; |
| 31 | 34 | ||
| @@ -40,6 +43,7 @@ export interface EventNdjsonLoggerOptions { · 8 unmodified lines | |||
| 40 | readonly maxBytes?: number; | 43 | readonly maxBytes?: number; |
| 41 | readonly maxFiles?: number; | 44 | readonly maxFiles?: number; |
| 42 | readonly batchWindowMs?: number; | 45 | readonly batchWindowMs?: number; |
| 46 | readonly attribution?: ResourceAttributionShape; | ||
| 43 | } | 47 | } |
| 44 | 48 | ||
| 45 | interface ThreadWriter { | 49 | interface ThreadWriter { |
| @@ -104,6 +108,8 @@ const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { · 58 unmodified lines | |||
| 104 | readonly maxFiles: number; | 108 | readonly maxFiles: number; |
| 105 | readonly batchWindowMs: number; | 109 | readonly batchWindowMs: number; |
| 106 | readonly streamLabel: string; | 110 | readonly streamLabel: string; |
| 111 | readonly stream: EventNdjsonStream; | ||
| 112 | readonly attribution?: ResourceAttributionShape; | ||
| 107 | }): Effect.fn.Return<ThreadWriter | undefined> { | 113 | }): Effect.fn.Return<ThreadWriter | undefined> { |
| 108 | const sinkResult = yield* Effect.sync(() => { | 114 | const sinkResult = yield* Effect.sync(() => { |
| 109 | try { | 115 | try { |
| @@ -135,12 +141,19 @@ const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { · 25 unmodified lines | |||
| 135 | const batchedLogger = yield* Logger.batched(lineLogger, { | 141 | const batchedLogger = yield* Logger.batched(lineLogger, { |
| 136 | window: input.batchWindowMs, | 142 | window: input.batchWindowMs, |
| 137 | flush: Effect.fn("makeThreadWriter.flush")(function* (messages) { | 143 | flush: Effect.fn("makeThreadWriter.flush")(function* (messages) { |
| 144 | const startedAt = yield* Clock.currentTimeMillis; | ||
| 138 | const flushResult = yield* Effect.sync(() => { | 145 | const flushResult = yield* Effect.sync(() => { |
| 139 | try { | 146 | try { |
| 147 | let logicalWriteBytes = 0; | ||
| 140 | for (const message of messages) { | 148 | for (const message of messages) { |
| 141 | sink.write(message); | 149 | sink.write(message); |
| 150 | logicalWriteBytes += textEncoder.encode(message).byteLength; | ||
| 142 | } | 151 | } |
| 143 | return { ok: true as const }; | 152 | return { |
| 153 | ok: true as const, | ||
| 154 | logicalWriteBytes, | ||
| 155 | count: messages.length, | ||
| 156 | }; | ||
| 144 | } catch (error) { | 157 | } catch (error) { |
| 145 | return { ok: false as const, error }; | 158 | return { ok: false as const, error }; |
| 146 | } | 159 | } |
| @@ -151,6 +164,18 @@ const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { · 4 unmodified lines | |||
| 151 | filePath: input.filePath, | 164 | filePath: input.filePath, |
| 152 | error: flushResult.error, | 165 | error: flushResult.error, |
| 153 | }); | 166 | }); |
| 167 | return; | ||
| 168 | } | ||
| 169 | |||
| 170 | if (input.attribution && flushResult.count > 0) { | ||
| 171 | const completedAt = yield* Clock.currentTimeMillis; | ||
| 172 | yield* input.attribution.record({ | ||
| 173 | component: "provider-event-log", | ||
| 174 | operation: `${input.stream}.append`, | ||
| 175 | logicalWriteBytes: flushResult.logicalWriteBytes, | ||
| 176 | count: flushResult.count, | ||
| 177 | durationMs: Math.max(0, completedAt - startedAt), | ||
| 178 | }); | ||
| 154 | } | 179 | } |
| 155 | }), | 180 | }), |
| 156 | }).pipe(Effect.provideService(Scope.Scope, scope)); | 181 | }).pipe(Effect.provideService(Scope.Scope, scope)); |
| @@ -216,6 +241,8 @@ export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function · 59 unmodified lines | |||
| 216 | maxFiles, | 241 | maxFiles, |
| 217 | batchWindowMs, | 242 | batchWindowMs, |
| 218 | streamLabel, | 243 | streamLabel, |
| 244 | stream: options.stream, | ||
| 245 | ...(options.attribution ? { attribution: options.attribution } : {}), | ||
| 219 | }).pipe( | 246 | }).pipe( |
| 220 | Effect.map((writer) => { | 247 | Effect.map((writer) => { |
| 221 | if (!writer) { | 248 | if (!writer) { |
| @@ -32,6 +32,7 @@ import * as Effect from "effect/Effect"; · 31 unmodified lines | |||
| 32 | import * as Layer from "effect/Layer"; | 32 | import * as Layer from "effect/Layer"; |
| 33 | 33 | ||
| 34 | import { ServerConfig } from "../../config.ts"; | 34 | import { ServerConfig } from "../../config.ts"; |
| 35 | import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; | ||
| 35 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 36 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 36 | 37 | ||
| 37 | export interface ProviderEventLoggersShape { | 38 | export interface ProviderEventLoggersShape { |
| @@ -71,11 +72,14 @@ export const ProviderEventLoggersLive = Layer.effect( · 33 unmodified lines | |||
| 71 | ProviderEventLoggers, | 72 | ProviderEventLoggers, |
| 72 | Effect.gen(function* () { | 73 | Effect.gen(function* () { |
| 73 | const { providerEventLogPath } = yield* ServerConfig; | 74 | const { providerEventLogPath } = yield* ServerConfig; |
| 75 | const attribution = yield* ResourceAttribution.ResourceAttribution; | ||
| 74 | const native = yield* makeEventNdjsonLogger(providerEventLogPath, { | 76 | const native = yield* makeEventNdjsonLogger(providerEventLogPath, { |
| 75 | stream: "native", | 77 | stream: "native", |
| 78 | attribution, | ||
| 76 | }); | 79 | }); |
| 77 | const canonical = yield* makeEventNdjsonLogger(providerEventLogPath, { | 80 | const canonical = yield* makeEventNdjsonLogger(providerEventLogPath, { |
| 78 | stream: "canonical", | 81 | stream: "canonical", |
| 82 | attribution, | ||
| 79 | }); | 83 | }); |
| 80 | return { | 84 | return { |
| 81 | native, | 85 | native, |
| @@ -0,0 +1,279 @@ | |||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeFileSystem from "node:fs"; | ||
| 3 | |||
| 4 | import * as NodeStream from "@effect/platform-node/NodeStream"; | ||
| 5 | import { | ||
| 6 | DesktopHostTelemetryMessage, | ||
| 7 | type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue, | ||
| 8 | type DesktopHostTelemetrySnapshot, | ||
| 9 | type ResourceTelemetrySourceStatus, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import * as Context from "effect/Context"; | ||
| 12 | import * as DateTime from "effect/DateTime"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import * as PubSub from "effect/PubSub"; | ||
| 17 | import * as Ref from "effect/Ref"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | import * as Stream from "effect/Stream"; | ||
| 20 | import * as Ndjson from "effect/unstable/encoding/Ndjson"; | ||
| 21 | |||
| 22 | import { ServerConfig } from "../config.ts"; | ||
| 23 | |||
| 24 | export class DesktopTelemetryDescriptorUnavailable extends Schema.TaggedErrorClass<DesktopTelemetryDescriptorUnavailable>()( | ||
| 25 | "DesktopTelemetryDescriptorUnavailable", | ||
| 26 | { | ||
| 27 | mode: Schema.String, | ||
| 28 | }, | ||
| 29 | ) { | ||
| 30 | override get message(): string { | ||
| 31 | return `Desktop telemetry descriptor is unavailable in '${this.mode}' mode.`; | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | export class DesktopTelemetryProtocolMismatch extends Schema.TaggedErrorClass<DesktopTelemetryProtocolMismatch>()( | ||
| 36 | "DesktopTelemetryProtocolMismatch", | ||
| 37 | { | ||
| 38 | expectedVersion: Schema.Number, | ||
| 39 | receivedVersion: Schema.Number, | ||
| 40 | }, | ||
| 41 | ) { | ||
| 42 | override get message(): string { | ||
| 43 | return `Desktop telemetry protocol ${this.receivedVersion} is incompatible with expected protocol ${this.expectedVersion}.`; | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | export class DesktopTelemetryDecodeFailed extends Schema.TaggedErrorClass<DesktopTelemetryDecodeFailed>()( | ||
| 48 | "DesktopTelemetryDecodeFailed", | ||
| 49 | { | ||
| 50 | cause: Schema.Defect(), | ||
| 51 | }, | ||
| 52 | ) { | ||
| 53 | override get message(): string { | ||
| 54 | return "Failed to decode desktop telemetry."; | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | export class DesktopTelemetryStreamFailed extends Schema.TaggedErrorClass<DesktopTelemetryStreamFailed>()( | ||
| 59 | "DesktopTelemetryStreamFailed", | ||
| 60 | { | ||
| 61 | fd: Schema.Number, | ||
| 62 | cause: Schema.Defect(), | ||
| 63 | }, | ||
| 64 | ) { | ||
| 65 | override get message(): string { | ||
| 66 | return `Desktop telemetry stream on fd ${this.fd} failed.`; | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | export class DesktopTelemetryStreamClosed extends Schema.TaggedErrorClass<DesktopTelemetryStreamClosed>()( | ||
| 71 | "DesktopTelemetryStreamClosed", | ||
| 72 | { | ||
| 73 | fd: Schema.Number, | ||
| 74 | }, | ||
| 75 | ) { | ||
| 76 | override get message(): string { | ||
| 77 | return `Desktop telemetry stream on fd ${this.fd} closed.`; | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | export type DesktopTelemetryReceiverError = | ||
| 82 | | DesktopTelemetryDescriptorUnavailable | ||
| 83 | | DesktopTelemetryProtocolMismatch | ||
| 84 | | DesktopTelemetryDecodeFailed | ||
| 85 | | DesktopTelemetryStreamFailed | ||
| 86 | | DesktopTelemetryStreamClosed; | ||
| 87 | |||
| 88 | export interface DesktopTelemetryReceiverHealth { | ||
| 89 | readonly status: ResourceTelemetrySourceStatus; | ||
| 90 | readonly lastSampleAt: Option.Option<DateTime.Utc>; | ||
| 91 | readonly lastError: Option.Option<string>; | ||
| 92 | } | ||
| 93 | |||
| 94 | export interface DesktopTelemetryReceiverShape { | ||
| 95 | readonly latest: Effect.Effect<Option.Option<DesktopHostTelemetrySnapshot>>; | ||
| 96 | readonly changes: Stream.Stream<DesktopHostTelemetrySnapshot>; | ||
| 97 | readonly health: Effect.Effect<DesktopTelemetryReceiverHealth>; | ||
| 98 | readonly healthChanges: Stream.Stream<DesktopTelemetryReceiverHealth>; | ||
| 99 | } | ||
| 100 | |||
| 101 | export class DesktopTelemetryReceiver extends Context.Service< | ||
| 102 | DesktopTelemetryReceiver, | ||
| 103 | DesktopTelemetryReceiverShape | ||
| 104 | >()("t3/resourceTelemetry/DesktopTelemetryReceiver") {} | ||
| 105 | |||
| 106 | const decodeMessage = Schema.decodeUnknownEffect(DesktopHostTelemetryMessage); | ||
| 107 | const isDescriptorUnavailable = Schema.is(DesktopTelemetryDescriptorUnavailable); | ||
| 108 | const isProtocolMismatch = Schema.is(DesktopTelemetryProtocolMismatch); | ||
| 109 | const isDecodeFailed = Schema.is(DesktopTelemetryDecodeFailed); | ||
| 110 | const isStreamFailed = Schema.is(DesktopTelemetryStreamFailed); | ||
| 111 | |||
| 112 | function normalizeReceiverError(error: unknown): DesktopTelemetryReceiverError { | ||
| 113 | if ( | ||
| 114 | isDescriptorUnavailable(error) || | ||
| 115 | isProtocolMismatch(error) || | ||
| 116 | isDecodeFailed(error) || | ||
| 117 | isStreamFailed(error) | ||
| 118 | ) { | ||
| 119 | return error; | ||
| 120 | } | ||
| 121 | return new DesktopTelemetryDecodeFailed({ cause: error }); | ||
| 122 | } | ||
| 123 | |||
| 124 | function messageVersion(value: unknown): number | undefined { | ||
| 125 | if (typeof value !== "object" || value === null) return undefined; | ||
| 126 | const version = Reflect.get(value, "version"); | ||
| 127 | return typeof version === "number" ? version : undefined; | ||
| 128 | } | ||
| 129 | |||
| 130 | export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")(function* () { | ||
| 131 | const config = yield* ServerConfig; | ||
| 132 | const latest = yield* Ref.make(Option.none<DesktopHostTelemetrySnapshot>()); | ||
| 133 | const changes = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(8); | ||
| 134 | const healthChanges = yield* PubSub.sliding<DesktopTelemetryReceiverHealth>(4); | ||
| 135 | const health = yield* Ref.make<DesktopTelemetryReceiverHealth>({ | ||
| 136 | status: config.desktopTelemetryFd === undefined ? "unavailable" : "starting", | ||
| 137 | lastSampleAt: Option.none(), | ||
| 138 | lastError: | ||
| 139 | config.desktopTelemetryFd === undefined | ||
| 140 | ? Option.some( | ||
| 141 | new DesktopTelemetryDescriptorUnavailable({ | ||
| 142 | mode: config.mode, | ||
| 143 | }).message, | ||
| 144 | ) | ||
| 145 | : Option.none(), | ||
| 146 | }); | ||
| 147 | const updateHealth = ( | ||
| 148 | update: (current: DesktopTelemetryReceiverHealth) => DesktopTelemetryReceiverHealth, | ||
| 149 | ) => | ||
| 150 | Ref.modify(health, (current) => { | ||
| 151 | const next = update(current); | ||
| 152 | return [next, next]; | ||
| 153 | }).pipe( | ||
| 154 | Effect.flatMap((next) => PubSub.publish(healthChanges, next)), | ||
| 155 | Effect.asVoid, | ||
| 156 | ); | ||
| 157 | |||
| 158 | if (config.desktopTelemetryFd !== undefined) { | ||
| 159 | const fd = config.desktopTelemetryFd; | ||
| 160 | const readable = yield* Effect.acquireRelease( | ||
| 161 | Effect.try({ | ||
| 162 | try: () => | ||
| 163 | NodeFileSystem.createReadStream("", { | ||
| 164 | fd, | ||
| 165 | autoClose: true, | ||
| 166 | }), | ||
| 167 | catch: (cause) => new DesktopTelemetryStreamFailed({ fd, cause }), | ||
| 168 | }), | ||
| 169 | (stream) => | ||
| 170 | Effect.sync(() => { | ||
| 171 | stream.destroy(); | ||
| 172 | }), | ||
| 173 | ); | ||
| 174 | |||
| 175 | const messages: Stream.Stream<DesktopHostTelemetryMessageValue, DesktopTelemetryReceiverError> = | ||
| 176 | NodeStream.fromReadable<Uint8Array, DesktopTelemetryStreamFailed>({ | ||
| 177 | evaluate: () => readable, | ||
| 178 | closeOnDone: true, | ||
| 179 | onError: (cause) => new DesktopTelemetryStreamFailed({ fd, cause }), | ||
| 180 | }).pipe( | ||
| 181 | Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })), | ||
| 182 | Stream.mapEffect( | ||
| 183 | ( | ||
| 184 | value, | ||
| 185 | ): Effect.Effect< | ||
| 186 | DesktopHostTelemetryMessageValue, | ||
| 187 | DesktopTelemetryProtocolMismatch | DesktopTelemetryDecodeFailed | ||
| 188 | > => { | ||
| 189 | const version = messageVersion(value); | ||
| 190 | if (version !== undefined && version !== 1) { | ||
| 191 | return Effect.fail( | ||
| 192 | new DesktopTelemetryProtocolMismatch({ | ||
| 193 | expectedVersion: 1, | ||
| 194 | receivedVersion: version, | ||
| 195 | }), | ||
| 196 | ); | ||
| 197 | } | ||
| 198 | return decodeMessage(value).pipe( | ||
| 199 | Effect.mapError((cause) => new DesktopTelemetryDecodeFailed({ cause })), | ||
| 200 | ); | ||
| 201 | }, | ||
| 202 | ), | ||
| 203 | Stream.mapError(normalizeReceiverError), | ||
| 204 | ); | ||
| 205 | |||
| 206 | yield* messages.pipe( | ||
| 207 | Stream.runForEach((message) => { | ||
| 208 | if (message.type === "desktopTelemetryHello") { | ||
| 209 | return updateHealth( | ||
| 210 | (current): DesktopTelemetryReceiverHealth => ({ | ||
| 211 | ...current, | ||
| 212 | status: "healthy", | ||
| 213 | lastError: Option.none(), | ||
| 214 | }), | ||
| 215 | ); | ||
| 216 | } | ||
| 217 | |||
| 218 | const sampledAt = DateTime.makeUnsafe(message.sampledAtUnixMs); | ||
| 219 | return Ref.set(latest, Option.some(message)).pipe( | ||
| 220 | Effect.andThen( | ||
| 221 | Ref.set(health, { | ||
| 222 | status: "healthy", | ||
| 223 | lastSampleAt: Option.some(sampledAt), | ||
| 224 | lastError: Option.none(), | ||
| 225 | }), | ||
| 226 | ), | ||
| 227 | Effect.andThen(PubSub.publish(changes, message)), | ||
| 228 | Effect.asVoid, | ||
| 229 | ); | ||
| 230 | }), | ||
| 231 | Effect.andThen( | ||
| 232 | updateHealth( | ||
| 233 | (current): DesktopTelemetryReceiverHealth => ({ | ||
| 234 | ...current, | ||
| 235 | status: "stopped", | ||
| 236 | lastError: Option.some(new DesktopTelemetryStreamClosed({ fd }).message), | ||
| 237 | }), | ||
| 238 | ), | ||
| 239 | ), | ||
| 240 | Effect.catch((error) => | ||
| 241 | updateHealth( | ||
| 242 | (current): DesktopTelemetryReceiverHealth => ({ | ||
| 243 | ...current, | ||
| 244 | status: "degraded", | ||
| 245 | lastError: Option.some(error.message), | ||
| 246 | }), | ||
| 247 | ), | ||
| 248 | ), | ||
| 249 | Effect.forkScoped, | ||
| 250 | ); | ||
| 251 | } | ||
| 252 | |||
| 253 | return DesktopTelemetryReceiver.of({ | ||
| 254 | latest: Ref.get(latest), | ||
| 255 | changes: Stream.fromPubSub(changes), | ||
| 256 | health: Ref.get(health), | ||
| 257 | healthChanges: Stream.fromPubSub(healthChanges), | ||
| 258 | }); | ||
| 259 | }); | ||
| 260 | |||
| 261 | export const layer = Layer.effect(DesktopTelemetryReceiver, make()); | ||
| 262 | |||
| 263 | export const layerTest = ( | ||
| 264 | overrides: Partial<DesktopTelemetryReceiverShape> = {}, | ||
| 265 | ): Layer.Layer<DesktopTelemetryReceiver> => | ||
| 266 | Layer.succeed( | ||
| 267 | DesktopTelemetryReceiver, | ||
| 268 | DesktopTelemetryReceiver.of({ | ||
| 269 | latest: Effect.succeedNone, | ||
| 270 | changes: Stream.empty, | ||
| 271 | health: Effect.succeed({ | ||
| 272 | status: "unavailable", | ||
| 273 | lastSampleAt: Option.none(), | ||
| 274 | lastError: Option.some("Desktop telemetry test implementation is unavailable."), | ||
| 275 | }), | ||
| 276 | healthChanges: Stream.empty, | ||
| 277 | ...overrides, | ||
| 278 | }), | ||
| 279 | ); | ||
| @@ -0,0 +1,393 @@ | |||
| 1 | import { | ||
| 2 | type DesktopElectronProcessMetric, | ||
| 3 | type DesktopHostTelemetrySnapshot, | ||
| 4 | type ResourceMonitorProcessSample, | ||
| 5 | type ResourceMonitorSnapshotEvent, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { describe, expect, it } from "@effect/vitest"; | ||
| 8 | import * as DateTime from "effect/DateTime"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | |||
| 11 | import { emptyTelemetryCounters, mergeProcesses, type MergeProcessesResult } from "./Model.ts"; | ||
| 12 | |||
| 13 | const SERVER_PID = 100; | ||
| 14 | const BASE_TIME_MS = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-06-17T12:00:00.000Z")); | ||
| 15 | |||
| 16 | function processSample( | ||
| 17 | input: Partial<ResourceMonitorProcessSample> & | ||
| 18 | Pick<ResourceMonitorProcessSample, "pid" | "ppid" | "startTimeMs">, | ||
| 19 | ): ResourceMonitorProcessSample { | ||
| 20 | return { | ||
| 21 | runTimeMs: 1_000, | ||
| 22 | name: `process-${input.pid}`, | ||
| 23 | command: `process-${input.pid}`, | ||
| 24 | status: "Running", | ||
| 25 | cpuPercent: 0, | ||
| 26 | cpuTimeMs: 0, | ||
| 27 | residentBytes: 1_024, | ||
| 28 | virtualBytes: 2_048, | ||
| 29 | ioReadBytes: 0, | ||
| 30 | ioWriteBytes: 0, | ||
| 31 | ioSemantics: "storage", | ||
| 32 | ...input, | ||
| 33 | }; | ||
| 34 | } | ||
| 35 | |||
| 36 | function nativeSnapshot( | ||
| 37 | sampledAtUnixMs: number, | ||
| 38 | processes: ReadonlyArray<ResourceMonitorProcessSample>, | ||
| 39 | sequence = 1, | ||
| 40 | ): ResourceMonitorSnapshotEvent { | ||
| 41 | return { | ||
| 42 | version: 1, | ||
| 43 | type: "snapshot", | ||
| 44 | sequence, | ||
| 45 | sampledAtUnixMs, | ||
| 46 | collectionDurationMicros: 250, | ||
| 47 | scannedProcessCount: processes.length, | ||
| 48 | retainedProcessCount: processes.length, | ||
| 49 | inaccessibleProcessCount: 0, | ||
| 50 | processes: [...processes], | ||
| 51 | }; | ||
| 52 | } | ||
| 53 | |||
| 54 | function electronMetric( | ||
| 55 | input: Partial<DesktopElectronProcessMetric> & | ||
| 56 | Pick<DesktopElectronProcessMetric, "pid" | "creationTimeMs" | "type">, | ||
| 57 | ): DesktopElectronProcessMetric { | ||
| 58 | return { | ||
| 59 | cpuPercent: 0, | ||
| 60 | idleWakeupsPerSecond: 0, | ||
| 61 | workingSetBytes: 1_024, | ||
| 62 | peakWorkingSetBytes: 2_048, | ||
| 63 | ...input, | ||
| 64 | }; | ||
| 65 | } | ||
| 66 | |||
| 67 | function desktopSnapshot( | ||
| 68 | sampledAtUnixMs: number, | ||
| 69 | electronProcesses: ReadonlyArray<DesktopElectronProcessMetric>, | ||
| 70 | ): DesktopHostTelemetrySnapshot { | ||
| 71 | const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs); | ||
| 72 | return { | ||
| 73 | version: 1, | ||
| 74 | type: "desktopTelemetry", | ||
| 75 | sequence: 1, | ||
| 76 | sampledAtUnixMs, | ||
| 77 | power: { | ||
| 78 | source: "electron-main", | ||
| 79 | idle: "false", | ||
| 80 | idleSeconds: 0, | ||
| 81 | locked: "false", | ||
| 82 | suspended: false, | ||
| 83 | onBattery: "false", | ||
| 84 | lowPowerMode: "unknown", | ||
| 85 | thermalState: "nominal", | ||
| 86 | stale: false, | ||
| 87 | updatedAt: sampledAt, | ||
| 88 | }, | ||
| 89 | speedLimitPercent: Option.none(), | ||
| 90 | electronProcesses: [...electronProcesses], | ||
| 91 | }; | ||
| 92 | } | ||
| 93 | |||
| 94 | function merge(input: { | ||
| 95 | readonly native: ResourceMonitorSnapshotEvent; | ||
| 96 | readonly desktop?: DesktopHostTelemetrySnapshot; | ||
| 97 | readonly previous?: MergeProcessesResult; | ||
| 98 | readonly sidecarPid?: number; | ||
| 99 | }): MergeProcessesResult { | ||
| 100 | return mergeProcesses({ | ||
| 101 | serverPid: SERVER_PID, | ||
| 102 | sidecarPid: Option.fromUndefinedOr(input.sidecarPid), | ||
| 103 | fallbackSampledAtMs: input.native.sampledAtUnixMs, | ||
| 104 | nativeSnapshot: Option.some(input.native), | ||
| 105 | desktopSnapshot: Option.fromUndefinedOr(input.desktop), | ||
| 106 | previous: input.previous?.previous ?? new Map(), | ||
| 107 | counters: input.previous?.counters ?? emptyTelemetryCounters(), | ||
| 108 | updatePrevious: true, | ||
| 109 | }); | ||
| 110 | } | ||
| 111 | |||
| 112 | describe("resource telemetry process model", () => { | ||
| 113 | it("builds complete descendant depths and isolates monitor overhead", () => { | ||
| 114 | const result = merge({ | ||
| 115 | sidecarPid: 900, | ||
| 116 | native: nativeSnapshot(BASE_TIME_MS, [ | ||
| 117 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), | ||
| 118 | processSample({ pid: 200, ppid: SERVER_PID, startTimeMs: 2_000 }), | ||
| 119 | processSample({ pid: 201, ppid: 200, startTimeMs: 3_000 }), | ||
| 120 | processSample({ pid: 202, ppid: 201, startTimeMs: 4_000 }), | ||
| 121 | processSample({ pid: 900, ppid: SERVER_PID, startTimeMs: 5_000 }), | ||
| 122 | ]), | ||
| 123 | }); | ||
| 124 | |||
| 125 | expect(result.processes.map((process) => [process.identity.pid, process.depth])).toEqual([ | ||
| 126 | [100, 0], | ||
| 127 | [200, 1], | ||
| 128 | [201, 2], | ||
| 129 | [202, 3], | ||
| 130 | [900, 1], | ||
| 131 | ]); | ||
| 132 | expect(result.processes.find((process) => process.identity.pid === 900)?.category).toBe( | ||
| 133 | "resource-monitor", | ||
| 134 | ); | ||
| 135 | expect(result.groups.backend.processCount).toBe(4); | ||
| 136 | expect(result.groups.monitor.processCount).toBe(1); | ||
| 137 | expect(result.groups.monitor.processStarts).toBe(1); | ||
| 138 | expect(result.groups.allT3.processStarts).toBe(5); | ||
| 139 | }); | ||
| 140 | |||
| 141 | it("deduplicates Electron metrics and classifies Electron descendants", () => { | ||
| 142 | const electronStart = 10_000; | ||
| 143 | const result = merge({ | ||
| 144 | native: nativeSnapshot(BASE_TIME_MS, [ | ||
| 145 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), | ||
| 146 | processSample({ pid: 300, ppid: 1, startTimeMs: electronStart }), | ||
| 147 | processSample({ pid: 301, ppid: 300, startTimeMs: electronStart + 1 }), | ||
| 148 | ]), | ||
| 149 | desktop: desktopSnapshot(BASE_TIME_MS, [ | ||
| 150 | electronMetric({ | ||
| 151 | pid: 300, | ||
| 152 | creationTimeMs: electronStart + 500, | ||
| 153 | type: "Browser", | ||
| 154 | name: "electron", | ||
| 155 | }), | ||
| 156 | electronMetric({ | ||
| 157 | pid: 301, | ||
| 158 | creationTimeMs: electronStart + 500, | ||
| 159 | type: "Utility", | ||
| 160 | name: "network-service", | ||
| 161 | }), | ||
| 162 | ]), | ||
| 163 | }); | ||
| 164 | |||
| 165 | expect(result.processes.filter((process) => process.identity.pid === 300)).toHaveLength(1); | ||
| 166 | expect(result.processes.find((process) => process.identity.pid === 300)?.category).toBe( | ||
| 167 | "electron-main", | ||
| 168 | ); | ||
| 169 | expect(result.processes.find((process) => process.identity.pid === 301)?.category).toBe( | ||
| 170 | "electron-utility", | ||
| 171 | ); | ||
| 172 | expect(result.processes.find((process) => process.identity.pid === 301)?.depth).toBe(1); | ||
| 173 | expect(result.groups.electron.processCount).toBe(2); | ||
| 174 | }); | ||
| 175 | |||
| 176 | it("ignores stale Electron metrics after PID reuse", () => { | ||
| 177 | const result = merge({ | ||
| 178 | native: nativeSnapshot(BASE_TIME_MS, [ | ||
| 179 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), | ||
| 180 | processSample({ pid: 300, ppid: SERVER_PID, startTimeMs: 50_000 }), | ||
| 181 | ]), | ||
| 182 | desktop: desktopSnapshot(BASE_TIME_MS, [ | ||
| 183 | electronMetric({ | ||
| 184 | pid: 300, | ||
| 185 | creationTimeMs: 10_000, | ||
| 186 | type: "Browser", | ||
| 187 | }), | ||
| 188 | ]), | ||
| 189 | }); | ||
| 190 | |||
| 191 | expect(result.processes.find((process) => process.identity.pid === 300)?.category).toBe( | ||
| 192 | "server-child", | ||
| 193 | ); | ||
| 194 | expect(result.groups.electron.processCount).toBe(0); | ||
| 195 | }); | ||
| 196 | |||
| 197 | it("derives rates from cumulative counters and preserves I/O semantics", () => { | ||
| 198 | const first = merge({ | ||
| 199 | native: nativeSnapshot(BASE_TIME_MS, [ | ||
| 200 | processSample({ | ||
| 201 | pid: SERVER_PID, | ||
| 202 | ppid: 1, | ||
| 203 | startTimeMs: 1_000, | ||
| 204 | cpuTimeMs: 1_000, | ||
| 205 | ioReadBytes: 10_000, | ||
| 206 | ioWriteBytes: 20_000, | ||
| 207 | ioSemantics: "all-io", | ||
| 208 | }), | ||
| 209 | ]), | ||
| 210 | }); | ||
| 211 | const second = merge({ | ||
| 212 | previous: first, | ||
| 213 | native: nativeSnapshot( | ||
| 214 | BASE_TIME_MS + 1_000, | ||
| 215 | [ | ||
| 216 | processSample({ | ||
| 217 | pid: SERVER_PID, | ||
| 218 | ppid: 1, | ||
| 219 | startTimeMs: 1_000, | ||
| 220 | cpuTimeMs: 1_250, | ||
| 221 | ioReadBytes: 12_000, | ||
| 222 | ioWriteBytes: 23_000, | ||
| 223 | ioSemantics: "all-io", | ||
| 224 | }), | ||
| 225 | ], | ||
| 226 | 2, | ||
| 227 | ), | ||
| 228 | }); | ||
| 229 | const server = second.processes[0]!; | ||
| 230 | |||
| 231 | expect(server.cpuPercent).toBe(25); | ||
| 232 | expect(server.ioReadBytesPerSecond).toBe(2_000); | ||
| 233 | expect(server.ioWriteBytesPerSecond).toBe(3_000); | ||
| 234 | expect(server.ioSemantics).toBe("all-io"); | ||
| 235 | expect(second.groups.backend.cpuTimeMs).toBe(250); | ||
| 236 | expect(second.groups.backend.ioReadBytes).toBe(2_000); | ||
| 237 | expect(second.groups.backend.ioWriteBytes).toBe(3_000); | ||
| 238 | }); | ||
| 239 | |||
| 240 | it("preserves native rates while applying a desktop-only update", () => { | ||
| 241 | const first = merge({ | ||
| 242 | native: nativeSnapshot(BASE_TIME_MS, [ | ||
| 243 | processSample({ | ||
| 244 | pid: SERVER_PID, | ||
| 245 | ppid: 1, | ||
| 246 | startTimeMs: 1_000, | ||
| 247 | cpuTimeMs: 1_000, | ||
| 248 | ioReadBytes: 10_000, | ||
| 249 | ioWriteBytes: 20_000, | ||
| 250 | }), | ||
| 251 | ]), | ||
| 252 | }); | ||
| 253 | const second = merge({ | ||
| 254 | previous: first, | ||
| 255 | native: nativeSnapshot( | ||
| 256 | BASE_TIME_MS + 1_000, | ||
| 257 | [ | ||
| 258 | processSample({ | ||
| 259 | pid: SERVER_PID, | ||
| 260 | ppid: 1, | ||
| 261 | startTimeMs: 1_000, | ||
| 262 | cpuTimeMs: 1_250, | ||
| 263 | ioReadBytes: 12_000, | ||
| 264 | ioWriteBytes: 23_000, | ||
| 265 | }), | ||
| 266 | ], | ||
| 267 | 2, | ||
| 268 | ), | ||
| 269 | }); | ||
| 270 | const desktopOnly = mergeProcesses({ | ||
| 271 | serverPid: SERVER_PID, | ||
| 272 | sidecarPid: Option.none(), | ||
| 273 | fallbackSampledAtMs: BASE_TIME_MS + 1_000, | ||
| 274 | nativeSnapshot: Option.some( | ||
| 275 | nativeSnapshot( | ||
| 276 | BASE_TIME_MS + 1_000, | ||
| 277 | [ | ||
| 278 | processSample({ | ||
| 279 | pid: SERVER_PID, | ||
| 280 | ppid: 1, | ||
| 281 | startTimeMs: 1_000, | ||
| 282 | cpuTimeMs: 1_250, | ||
| 283 | ioReadBytes: 12_000, | ||
| 284 | ioWriteBytes: 23_000, | ||
| 285 | }), | ||
| 286 | ], | ||
| 287 | 2, | ||
| 288 | ), | ||
| 289 | ), | ||
| 290 | desktopSnapshot: Option.some(desktopSnapshot(BASE_TIME_MS + 1_500, [])), | ||
| 291 | previous: second.previous, | ||
| 292 | counters: second.counters, | ||
| 293 | updatePrevious: false, | ||
| 294 | }); | ||
| 295 | |||
| 296 | expect(desktopOnly.processes[0]?.cpuPercent).toBe(25); | ||
| 297 | expect(desktopOnly.processes[0]?.ioReadBytesPerSecond).toBe(2_000); | ||
| 298 | expect(desktopOnly.processes[0]?.ioWriteBytesPerSecond).toBe(3_000); | ||
| 299 | }); | ||
| 300 | |||
| 301 | it("resets deltas when counters decrease or the sampling gap is unsafe", () => { | ||
| 302 | const first = merge({ | ||
| 303 | native: nativeSnapshot(BASE_TIME_MS, [ | ||
| 304 | processSample({ | ||
| 305 | pid: SERVER_PID, | ||
| 306 | ppid: 1, | ||
| 307 | startTimeMs: 1_000, | ||
| 308 | cpuTimeMs: 1_000, | ||
| 309 | ioReadBytes: 10_000, | ||
| 310 | ioWriteBytes: 20_000, | ||
| 311 | }), | ||
| 312 | ]), | ||
| 313 | }); | ||
| 314 | const decreased = merge({ | ||
| 315 | previous: first, | ||
| 316 | native: nativeSnapshot( | ||
| 317 | BASE_TIME_MS + 1_000, | ||
| 318 | [ | ||
| 319 | processSample({ | ||
| 320 | pid: SERVER_PID, | ||
| 321 | ppid: 1, | ||
| 322 | startTimeMs: 1_000, | ||
| 323 | cpuTimeMs: 100, | ||
| 324 | ioReadBytes: 100, | ||
| 325 | ioWriteBytes: 200, | ||
| 326 | }), | ||
| 327 | ], | ||
| 328 | 2, | ||
| 329 | ), | ||
| 330 | }); | ||
| 331 | const delayed = merge({ | ||
| 332 | previous: decreased, | ||
| 333 | native: nativeSnapshot( | ||
| 334 | BASE_TIME_MS + 20_000, | ||
| 335 | [ | ||
| 336 | processSample({ | ||
| 337 | pid: SERVER_PID, | ||
| 338 | ppid: 1, | ||
| 339 | startTimeMs: 1_000, | ||
| 340 | cpuTimeMs: 10_000, | ||
| 341 | ioReadBytes: 100_000, | ||
| 342 | ioWriteBytes: 200_000, | ||
| 343 | }), | ||
| 344 | ], | ||
| 345 | 3, | ||
| 346 | ), | ||
| 347 | }); | ||
| 348 | |||
| 349 | expect(decreased.processes[0]?.cpuPercent).toBe(0); | ||
| 350 | expect(decreased.processes[0]?.ioReadBytesPerSecond).toBe(0); | ||
| 351 | expect(decreased.processes[0]?.ioWriteBytesPerSecond).toBe(0); | ||
| 352 | expect(delayed.processes[0]?.cpuPercent).toBe(0); | ||
| 353 | expect(delayed.processes[0]?.ioReadBytesPerSecond).toBe(0); | ||
| 354 | expect(delayed.processes[0]?.ioWriteBytesPerSecond).toBe(0); | ||
| 355 | expect(delayed.groups.backend.cpuTimeMs).toBe(0); | ||
| 356 | expect(delayed.groups.backend.ioReadBytes).toBe(0); | ||
| 357 | expect(delayed.groups.backend.ioWriteBytes).toBe(0); | ||
| 358 | }); | ||
| 359 | |||
| 360 | it("treats reused PIDs as an exit plus a new process", () => { | ||
| 361 | const first = merge({ | ||
| 362 | native: nativeSnapshot(BASE_TIME_MS, [ | ||
| 363 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), | ||
| 364 | processSample({ pid: 200, ppid: SERVER_PID, startTimeMs: 2_000 }), | ||
| 365 | ]), | ||
| 366 | }); | ||
| 367 | const second = merge({ | ||
| 368 | previous: first, | ||
| 369 | native: nativeSnapshot( | ||
| 370 | BASE_TIME_MS + 1_000, | ||
| 371 | [ | ||
| 372 | processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }), | ||
| 373 | processSample({ | ||
| 374 | pid: 200, | ||
| 375 | ppid: SERVER_PID, | ||
| 376 | startTimeMs: 9_000, | ||
| 377 | cpuTimeMs: 999, | ||
| 378 | ioReadBytes: 999, | ||
| 379 | ioWriteBytes: 999, | ||
| 380 | }), | ||
| 381 | ], | ||
| 382 | 2, | ||
| 383 | ), | ||
| 384 | }); | ||
| 385 | const reused = second.processes.find((process) => process.identity.pid === 200)!; | ||
| 386 | |||
| 387 | expect(reused.identity.startTimeMs).toBe(9_000); | ||
| 388 | expect(reused.cpuPercent).toBe(0); | ||
| 389 | expect(reused.ioReadBytesPerSecond).toBe(0); | ||
| 390 | expect(second.groups.backend.processStarts).toBe(3); | ||
| 391 | expect(second.groups.backend.processExits).toBe(1); | ||
| 392 | }); | ||
| 393 | }); | ||
| @@ -0,0 +1,542 @@ | |||
| 1 | import type { | ||
| 2 | DesktopElectronProcessMetric, | ||
| 3 | DesktopHostTelemetrySnapshot, | ||
| 4 | ResourceMonitorProcessSample, | ||
| 5 | ResourceMonitorSnapshotEvent, | ||
| 6 | ResourceTelemetryAggregate, | ||
| 7 | ResourceTelemetryProcess, | ||
| 8 | ResourceTelemetryProcessCategory, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as DateTime from "effect/DateTime"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | |||
| 13 | const MAX_DELTA_INTERVAL_MS = 10_000; | ||
| 14 | const ELECTRON_IDENTITY_TOLERANCE_MS = 2_000; | ||
| 15 | |||
| 16 | export interface ProcessState { | ||
| 17 | readonly process: ResourceTelemetryProcess; | ||
| 18 | readonly sampledAtMs: number; | ||
| 19 | } | ||
| 20 | |||
| 21 | export interface GroupCounters { | ||
| 22 | readonly cpuTimeMs: number; | ||
| 23 | readonly ioReadBytes: number; | ||
| 24 | readonly ioWriteBytes: number; | ||
| 25 | readonly processStarts: number; | ||
| 26 | readonly processExits: number; | ||
| 27 | } | ||
| 28 | |||
| 29 | export interface TelemetryCounters { | ||
| 30 | readonly backend: GroupCounters; | ||
| 31 | readonly electron: GroupCounters; | ||
| 32 | readonly monitor: GroupCounters; | ||
| 33 | readonly allT3: GroupCounters; | ||
| 34 | } | ||
| 35 | |||
| 36 | export interface ProcessDelta { | ||
| 37 | readonly identityKey: string; | ||
| 38 | readonly category: ResourceTelemetryProcessCategory; | ||
| 39 | readonly cpuTimeMs: number; | ||
| 40 | readonly ioReadBytes: number; | ||
| 41 | readonly ioWriteBytes: number; | ||
| 42 | } | ||
| 43 | |||
| 44 | export interface MergeProcessesInput { | ||
| 45 | readonly serverPid: number; | ||
| 46 | readonly sidecarPid: Option.Option<number>; | ||
| 47 | readonly fallbackSampledAtMs: number; | ||
| 48 | readonly nativeSnapshot: Option.Option<ResourceMonitorSnapshotEvent>; | ||
| 49 | readonly desktopSnapshot: Option.Option<DesktopHostTelemetrySnapshot>; | ||
| 50 | readonly previous: ReadonlyMap<string, ProcessState>; | ||
| 51 | readonly counters: TelemetryCounters; | ||
| 52 | readonly updatePrevious: boolean; | ||
| 53 | } | ||
| 54 | |||
| 55 | export interface MergeProcessesResult { | ||
| 56 | readonly sampledAtMs: number; | ||
| 57 | readonly processes: ReadonlyArray<ResourceTelemetryProcess>; | ||
| 58 | readonly previous: ReadonlyMap<string, ProcessState>; | ||
| 59 | readonly counters: TelemetryCounters; | ||
| 60 | readonly groups: { | ||
| 61 | readonly backend: ResourceTelemetryAggregate; | ||
| 62 | readonly electron: ResourceTelemetryAggregate; | ||
| 63 | readonly monitor: ResourceTelemetryAggregate; | ||
| 64 | readonly allT3: ResourceTelemetryAggregate; | ||
| 65 | }; | ||
| 66 | readonly deltas: ReadonlyArray<ProcessDelta>; | ||
| 67 | } | ||
| 68 | |||
| 69 | export const emptyGroupCounters = (): GroupCounters => ({ | ||
| 70 | cpuTimeMs: 0, | ||
| 71 | ioReadBytes: 0, | ||
| 72 | ioWriteBytes: 0, | ||
| 73 | processStarts: 0, | ||
| 74 | processExits: 0, | ||
| 75 | }); | ||
| 76 | |||
| 77 | export const emptyTelemetryCounters = (): TelemetryCounters => ({ | ||
| 78 | backend: emptyGroupCounters(), | ||
| 79 | electron: emptyGroupCounters(), | ||
| 80 | monitor: emptyGroupCounters(), | ||
| 81 | allT3: emptyGroupCounters(), | ||
| 82 | }); | ||
| 83 | |||
| 84 | export function processIdentityKey(pid: number, startTimeMs: number): string { | ||
| 85 | return `${pid}:${startTimeMs}`; | ||
| 86 | } | ||
| 87 | |||
| 88 | function finiteNonNegative(value: number): number { | ||
| 89 | return Number.isFinite(value) ? Math.max(0, value) : 0; | ||
| 90 | } | ||
| 91 | |||
| 92 | function categoryGroup( | ||
| 93 | category: ResourceTelemetryProcessCategory, | ||
| 94 | ): "backend" | "electron" | "monitor" { | ||
| 95 | if (category === "resource-monitor") return "monitor"; | ||
| 96 | if (category.startsWith("electron-")) return "electron"; | ||
| 97 | return "backend"; | ||
| 98 | } | ||
| 99 | |||
| 100 | function electronCategory(metric: DesktopElectronProcessMetric): ResourceTelemetryProcessCategory { | ||
| 101 | switch (metric.type) { | ||
| 102 | case "Browser": | ||
| 103 | return "electron-main"; | ||
| 104 | case "Tab": | ||
| 105 | return "electron-renderer"; | ||
| 106 | case "GPU": | ||
| 107 | return "electron-gpu"; | ||
| 108 | default: | ||
| 109 | return "electron-utility"; | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | function matchElectronMetric( | ||
| 114 | process: ResourceMonitorProcessSample, | ||
| 115 | metricsByPid: ReadonlyMap<number, DesktopElectronProcessMetric>, | ||
| 116 | ): DesktopElectronProcessMetric | undefined { | ||
| 117 | const metric = metricsByPid.get(process.pid); | ||
| 118 | if (!metric) return undefined; | ||
| 119 | return Math.abs(metric.creationTimeMs - process.startTimeMs) <= ELECTRON_IDENTITY_TOLERANCE_MS | ||
| 120 | ? metric | ||
| 121 | : undefined; | ||
| 122 | } | ||
| 123 | |||
| 124 | function syntheticNativeSample( | ||
| 125 | metric: DesktopElectronProcessMetric, | ||
| 126 | sampledAtMs: number, | ||
| 127 | ): ResourceMonitorProcessSample { | ||
| 128 | return { | ||
| 129 | pid: metric.pid, | ||
| 130 | ppid: 0, | ||
| 131 | startTimeMs: metric.creationTimeMs, | ||
| 132 | runTimeMs: Math.max(0, sampledAtMs - metric.creationTimeMs), | ||
| 133 | name: metric.name ?? metric.serviceName ?? metric.type, | ||
| 134 | command: metric.name ?? metric.serviceName ?? metric.type, | ||
| 135 | status: "Running", | ||
| 136 | cpuPercent: metric.cpuPercent, | ||
| 137 | cpuTimeMs: Math.max(0, Math.round((metric.cumulativeCpuSeconds ?? 0) * 1_000)), | ||
| 138 | residentBytes: metric.workingSetBytes, | ||
| 139 | virtualBytes: 0, | ||
| 140 | ioReadBytes: 0, | ||
| 141 | ioWriteBytes: 0, | ||
| 142 | ioSemantics: "storage", | ||
| 143 | }; | ||
| 144 | } | ||
| 145 | |||
| 146 | function processDepths( | ||
| 147 | processes: ReadonlyArray<ResourceMonitorProcessSample>, | ||
| 148 | roots: ReadonlySet<number>, | ||
| 149 | ): ReadonlyMap<number, number> { | ||
| 150 | const childrenByParent = new Map<number, number[]>(); | ||
| 151 | for (const process of processes) { | ||
| 152 | const children = childrenByParent.get(process.ppid) ?? []; | ||
| 153 | children.push(process.pid); | ||
| 154 | childrenByParent.set(process.ppid, children); | ||
| 155 | } | ||
| 156 | |||
| 157 | const depths = new Map<number, number>(); | ||
| 158 | const queue = [...roots].map((pid) => ({ pid, depth: 0 })); | ||
| 159 | while (queue.length > 0) { | ||
| 160 | const current = queue.shift(); | ||
| 161 | if (!current || depths.has(current.pid)) continue; | ||
| 162 | depths.set(current.pid, current.depth); | ||
| 163 | for (const childPid of childrenByParent.get(current.pid) ?? []) { | ||
| 164 | queue.push({ pid: childPid, depth: current.depth + 1 }); | ||
| 165 | } | ||
| 166 | } | ||
| 167 | return depths; | ||
| 168 | } | ||
| 169 | |||
| 170 | function isElectronDescendant( | ||
| 171 | pid: number, | ||
| 172 | processesByPid: ReadonlyMap<number, ResourceMonitorProcessSample>, | ||
| 173 | electronPids: ReadonlySet<number>, | ||
| 174 | ): boolean { | ||
| 175 | const visited = new Set<number>(); | ||
| 176 | let currentPid = pid; | ||
| 177 | while (!visited.has(currentPid)) { | ||
| 178 | visited.add(currentPid); | ||
| 179 | if (electronPids.has(currentPid)) return true; | ||
| 180 | const current = processesByPid.get(currentPid); | ||
| 181 | if (!current || current.ppid <= 0 || current.ppid === currentPid) return false; | ||
| 182 | currentPid = current.ppid; | ||
| 183 | } | ||
| 184 | return false; | ||
| 185 | } | ||
| 186 | |||
| 187 | function hasElectronAncestor( | ||
| 188 | process: ResourceMonitorProcessSample, | ||
| 189 | processesByPid: ReadonlyMap<number, ResourceMonitorProcessSample>, | ||
| 190 | electronPids: ReadonlySet<number>, | ||
| 191 | ): boolean { | ||
| 192 | const visited = new Set<number>(); | ||
| 193 | let currentPid = process.ppid; | ||
| 194 | while (currentPid > 0 && !visited.has(currentPid)) { | ||
| 195 | visited.add(currentPid); | ||
| 196 | if (electronPids.has(currentPid)) return true; | ||
| 197 | const current = processesByPid.get(currentPid); | ||
| 198 | if (!current || current.ppid === currentPid) return false; | ||
| 199 | currentPid = current.ppid; | ||
| 200 | } | ||
| 201 | return false; | ||
| 202 | } | ||
| 203 | |||
| 204 | function orderProcessTree( | ||
| 205 | processes: ReadonlyArray<ResourceTelemetryProcess>, | ||
| 206 | rootPids: ReadonlyArray<number>, | ||
| 207 | ): ReadonlyArray<ResourceTelemetryProcess> { | ||
| 208 | const processesByPid = new Map(processes.map((process) => [process.identity.pid, process])); | ||
| 209 | const childrenByParent = new Map<number, ResourceTelemetryProcess[]>(); | ||
| 210 | for (const process of processes) { | ||
| 211 | const children = childrenByParent.get(process.ppid) ?? []; | ||
| 212 | children.push(process); | ||
| 213 | childrenByParent.set(process.ppid, children); | ||
| 214 | } | ||
| 215 | for (const children of childrenByParent.values()) { | ||
| 216 | children.sort((left, right) => left.identity.pid - right.identity.pid); | ||
| 217 | } | ||
| 218 | |||
| 219 | const ordered: ResourceTelemetryProcess[] = []; | ||
| 220 | const visited = new Set<number>(); | ||
| 221 | const visit = (process: ResourceTelemetryProcess): void => { | ||
| 222 | if (visited.has(process.identity.pid)) return; | ||
| 223 | visited.add(process.identity.pid); | ||
| 224 | ordered.push(process); | ||
| 225 | for (const child of childrenByParent.get(process.identity.pid) ?? []) { | ||
| 226 | visit(child); | ||
| 227 | } | ||
| 228 | }; | ||
| 229 | |||
| 230 | for (const rootPid of rootPids) { | ||
| 231 | const root = processesByPid.get(rootPid); | ||
| 232 | if (root) visit(root); | ||
| 233 | } | ||
| 234 | for (const process of processes.toSorted( | ||
| 235 | (left, right) => left.depth - right.depth || left.identity.pid - right.identity.pid, | ||
| 236 | )) { | ||
| 237 | visit(process); | ||
| 238 | } | ||
| 239 | return ordered; | ||
| 240 | } | ||
| 241 | |||
| 242 | function delta(input: { | ||
| 243 | readonly current: number; | ||
| 244 | readonly previous: number; | ||
| 245 | readonly elapsedMs: number; | ||
| 246 | }): number { | ||
| 247 | if ( | ||
| 248 | input.elapsedMs <= 0 || | ||
| 249 | input.elapsedMs > MAX_DELTA_INTERVAL_MS || | ||
| 250 | input.current < input.previous | ||
| 251 | ) { | ||
| 252 | return 0; | ||
| 253 | } | ||
| 254 | return input.current - input.previous; | ||
| 255 | } | ||
| 256 | |||
| 257 | function incrementCounters(counters: GroupCounters, update: Partial<GroupCounters>): GroupCounters { | ||
| 258 | return { | ||
| 259 | cpuTimeMs: counters.cpuTimeMs + (update.cpuTimeMs ?? 0), | ||
| 260 | ioReadBytes: counters.ioReadBytes + (update.ioReadBytes ?? 0), | ||
| 261 | ioWriteBytes: counters.ioWriteBytes + (update.ioWriteBytes ?? 0), | ||
| 262 | processStarts: counters.processStarts + (update.processStarts ?? 0), | ||
| 263 | processExits: counters.processExits + (update.processExits ?? 0), | ||
| 264 | }; | ||
| 265 | } | ||
| 266 | |||
| 267 | function applyLifecycleCounters(input: { | ||
| 268 | readonly counters: TelemetryCounters; | ||
| 269 | readonly deltas: ReadonlyArray<ProcessDelta>; | ||
| 270 | readonly current: ReadonlyMap<string, ProcessState>; | ||
| 271 | readonly previous: ReadonlyMap<string, ProcessState>; | ||
| 272 | }): TelemetryCounters { | ||
| 273 | let backend = input.counters.backend; | ||
| 274 | let electron = input.counters.electron; | ||
| 275 | let monitor = input.counters.monitor; | ||
| 276 | let allT3 = input.counters.allT3; | ||
| 277 | for (const processDelta of input.deltas) { | ||
| 278 | const group = categoryGroup(processDelta.category); | ||
| 279 | switch (group) { | ||
| 280 | case "backend": | ||
| 281 | backend = incrementCounters(backend, processDelta); | ||
| 282 | break; | ||
| 283 | case "electron": | ||
| 284 | electron = incrementCounters(electron, processDelta); | ||
| 285 | break; | ||
| 286 | case "monitor": | ||
| 287 | monitor = incrementCounters(monitor, processDelta); | ||
| 288 | break; | ||
| 289 | } | ||
| 290 | allT3 = incrementCounters(allT3, processDelta); | ||
| 291 | } | ||
| 292 | |||
| 293 | for (const [identityKey, current] of input.current) { | ||
| 294 | if (input.previous.has(identityKey)) continue; | ||
| 295 | const group = categoryGroup(current.process.category); | ||
| 296 | switch (group) { | ||
| 297 | case "backend": | ||
| 298 | backend = incrementCounters(backend, { processStarts: 1 }); | ||
| 299 | break; | ||
| 300 | case "electron": | ||
| 301 | electron = incrementCounters(electron, { processStarts: 1 }); | ||
| 302 | break; | ||
| 303 | case "monitor": | ||
| 304 | monitor = incrementCounters(monitor, { processStarts: 1 }); | ||
| 305 | break; | ||
| 306 | } | ||
| 307 | allT3 = incrementCounters(allT3, { processStarts: 1 }); | ||
| 308 | } | ||
| 309 | |||
| 310 | for (const [identityKey, previous] of input.previous) { | ||
| 311 | if (input.current.has(identityKey)) continue; | ||
| 312 | const group = categoryGroup(previous.process.category); | ||
| 313 | switch (group) { | ||
| 314 | case "backend": | ||
| 315 | backend = incrementCounters(backend, { processExits: 1 }); | ||
| 316 | break; | ||
| 317 | case "electron": | ||
| 318 | electron = incrementCounters(electron, { processExits: 1 }); | ||
| 319 | break; | ||
| 320 | case "monitor": | ||
| 321 | monitor = incrementCounters(monitor, { processExits: 1 }); | ||
| 322 | break; | ||
| 323 | } | ||
| 324 | allT3 = incrementCounters(allT3, { processExits: 1 }); | ||
| 325 | } | ||
| 326 | |||
| 327 | return { backend, electron, monitor, allT3 }; | ||
| 328 | } | ||
| 329 | |||
| 330 | function aggregate( | ||
| 331 | processes: ReadonlyArray<ResourceTelemetryProcess>, | ||
| 332 | counters: GroupCounters, | ||
| 333 | ): ResourceTelemetryAggregate { | ||
| 334 | return { | ||
| 335 | processCount: processes.length, | ||
| 336 | currentCpuPercent: processes.reduce((total, process) => total + process.cpuPercent, 0), | ||
| 337 | cpuTimeMs: counters.cpuTimeMs, | ||
| 338 | currentRssBytes: processes.reduce((total, process) => total + process.residentBytes, 0), | ||
| 339 | peakRssBytes: processes.reduce((total, process) => total + process.peakResidentBytes, 0), | ||
| 340 | ioReadBytes: counters.ioReadBytes, | ||
| 341 | ioWriteBytes: counters.ioWriteBytes, | ||
| 342 | ioReadBytesPerSecond: processes.reduce( | ||
| 343 | (total, process) => total + process.ioReadBytesPerSecond, | ||
| 344 | 0, | ||
| 345 | ), | ||
| 346 | ioWriteBytesPerSecond: processes.reduce( | ||
| 347 | (total, process) => total + process.ioWriteBytesPerSecond, | ||
| 348 | 0, | ||
| 349 | ), | ||
| 350 | processStarts: counters.processStarts, | ||
| 351 | processExits: counters.processExits, | ||
| 352 | }; | ||
| 353 | } | ||
| 354 | |||
| 355 | export function mergeProcesses(input: MergeProcessesInput): MergeProcessesResult { | ||
| 356 | const nativeProcesses = Option.match(input.nativeSnapshot, { | ||
| 357 | onNone: () => [] as ReadonlyArray<ResourceMonitorProcessSample>, | ||
| 358 | onSome: (snapshot) => snapshot.processes, | ||
| 359 | }); | ||
| 360 | const electronMetrics = Option.match(input.desktopSnapshot, { | ||
| 361 | onNone: () => [] as ReadonlyArray<DesktopElectronProcessMetric>, | ||
| 362 | onSome: (snapshot) => snapshot.electronProcesses, | ||
| 363 | }); | ||
| 364 | const sampledAtMs = Option.match(input.nativeSnapshot, { | ||
| 365 | onNone: () => | ||
| 366 | Option.match(input.desktopSnapshot, { | ||
| 367 | onNone: () => input.fallbackSampledAtMs, | ||
| 368 | onSome: (snapshot) => snapshot.sampledAtUnixMs, | ||
| 369 | }), | ||
| 370 | onSome: (snapshot) => snapshot.sampledAtUnixMs, | ||
| 371 | }); | ||
| 372 | const nativeByPid = new Map(nativeProcesses.map((process) => [process.pid, process])); | ||
| 373 | const metricsByPid = new Map<number, DesktopElectronProcessMetric>(); | ||
| 374 | for (const metric of electronMetrics) { | ||
| 375 | const nativeProcess = nativeByPid.get(metric.pid); | ||
| 376 | if (!nativeProcess) { | ||
| 377 | nativeByPid.set(metric.pid, syntheticNativeSample(metric, sampledAtMs)); | ||
| 378 | metricsByPid.set(metric.pid, metric); | ||
| 379 | continue; | ||
| 380 | } | ||
| 381 | if ( | ||
| 382 | Math.abs(metric.creationTimeMs - nativeProcess.startTimeMs) <= ELECTRON_IDENTITY_TOLERANCE_MS | ||
| 383 | ) { | ||
| 384 | metricsByPid.set(metric.pid, metric); | ||
| 385 | } | ||
| 386 | } | ||
| 387 | const processes = [...nativeByPid.values()]; | ||
| 388 | const processesByPid = new Map(processes.map((process) => [process.pid, process])); | ||
| 389 | const electronPids = new Set(metricsByPid.keys()); | ||
| 390 | const electronRootPids = [...electronPids] | ||
| 391 | .filter((pid) => { | ||
| 392 | const process = processesByPid.get(pid); | ||
| 393 | return process === undefined | ||
| 394 | ? true | ||
| 395 | : !hasElectronAncestor(process, processesByPid, electronPids); | ||
| 396 | }) | ||
| 397 | .toSorted((left, right) => left - right); | ||
| 398 | const rootPids = [input.serverPid, ...electronRootPids]; | ||
| 399 | const roots = new Set(rootPids); | ||
| 400 | const depths = processDepths(processes, roots); | ||
| 401 | const childrenByParent = new Map<number, number[]>(); | ||
| 402 | for (const process of processes) { | ||
| 403 | const children = childrenByParent.get(process.ppid) ?? []; | ||
| 404 | children.push(process.pid); | ||
| 405 | childrenByParent.set(process.ppid, children); | ||
| 406 | } | ||
| 407 | |||
| 408 | const nextPrevious = new Map<string, ProcessState>(); | ||
| 409 | const processDeltas: ProcessDelta[] = []; | ||
| 410 | const normalized = processes.map((process): ResourceTelemetryProcess => { | ||
| 411 | const identityKey = processIdentityKey(process.pid, process.startTimeMs); | ||
| 412 | const previous = input.previous.get(identityKey); | ||
| 413 | const elapsedMs = previous ? sampledAtMs - previous.sampledAtMs : 0; | ||
| 414 | const cpuTimeDelta = previous | ||
| 415 | ? delta({ | ||
| 416 | current: process.cpuTimeMs, | ||
| 417 | previous: previous.process.cpuTimeMs, | ||
| 418 | elapsedMs, | ||
| 419 | }) | ||
| 420 | : 0; | ||
| 421 | const ioReadDelta = previous | ||
| 422 | ? delta({ | ||
| 423 | current: process.ioReadBytes, | ||
| 424 | previous: previous.process.ioReadBytes, | ||
| 425 | elapsedMs, | ||
| 426 | }) | ||
| 427 | : 0; | ||
| 428 | const ioWriteDelta = previous | ||
| 429 | ? delta({ | ||
| 430 | current: process.ioWriteBytes, | ||
| 431 | previous: previous.process.ioWriteBytes, | ||
| 432 | elapsedMs, | ||
| 433 | }) | ||
| 434 | : 0; | ||
| 435 | const electronMetric = matchElectronMetric(process, metricsByPid); | ||
| 436 | const category: ResourceTelemetryProcessCategory = | ||
| 437 | process.pid === input.serverPid | ||
| 438 | ? "server" | ||
| 439 | : Option.contains(input.sidecarPid, process.pid) | ||
| 440 | ? "resource-monitor" | ||
| 441 | : electronMetric | ||
| 442 | ? electronCategory(electronMetric) | ||
| 443 | : isElectronDescendant(process.pid, processesByPid, electronPids) | ||
| 444 | ? "electron-utility" | ||
| 445 | : "server-child"; | ||
| 446 | const firstSeenAt = previous?.process.firstSeenAt ?? DateTime.makeUnsafe(sampledAtMs); | ||
| 447 | const preservePreviousRates = !input.updatePrevious && previous !== undefined; | ||
| 448 | const cpuPercent = preservePreviousRates | ||
| 449 | ? previous.process.cpuPercent | ||
| 450 | : previous && elapsedMs > 0 && elapsedMs <= MAX_DELTA_INTERVAL_MS | ||
| 451 | ? (cpuTimeDelta / elapsedMs) * 100 | ||
| 452 | : finiteNonNegative(process.cpuPercent); | ||
| 453 | const normalizedProcess: ResourceTelemetryProcess = { | ||
| 454 | identity: { | ||
| 455 | pid: process.pid, | ||
| 456 | startTimeMs: process.startTimeMs, | ||
| 457 | }, | ||
| 458 | ppid: process.ppid, | ||
| 459 | childPids: [...(childrenByParent.get(process.pid) ?? [])].toSorted( | ||
| 460 | (left, right) => left - right, | ||
| 461 | ), | ||
| 462 | depth: depths.get(process.pid) ?? 0, | ||
| 463 | name: process.name, | ||
| 464 | command: process.command, | ||
| 465 | status: process.status, | ||
| 466 | category, | ||
| 467 | ...(electronMetric ? { electronType: electronMetric.type } : {}), | ||
| 468 | ...(electronMetric?.serviceName ? { electronServiceName: electronMetric.serviceName } : {}), | ||
| 469 | cpuPercent: finiteNonNegative(cpuPercent), | ||
| 470 | cpuTimeMs: process.cpuTimeMs, | ||
| 471 | residentBytes: process.residentBytes, | ||
| 472 | peakResidentBytes: Math.max( | ||
| 473 | process.residentBytes, | ||
| 474 | electronMetric?.peakWorkingSetBytes ?? 0, | ||
| 475 | previous?.process.peakResidentBytes ?? 0, | ||
| 476 | ), | ||
| 477 | virtualBytes: process.virtualBytes, | ||
| 478 | ioReadBytes: process.ioReadBytes, | ||
| 479 | ioWriteBytes: process.ioWriteBytes, | ||
| 480 | ioReadBytesPerSecond: preservePreviousRates | ||
| 481 | ? previous.process.ioReadBytesPerSecond | ||
| 482 | : elapsedMs > 0 | ||
| 483 | ? finiteNonNegative((ioReadDelta * 1_000) / elapsedMs) | ||
| 484 | : 0, | ||
| 485 | ioWriteBytesPerSecond: preservePreviousRates | ||
| 486 | ? previous.process.ioWriteBytesPerSecond | ||
| 487 | : elapsedMs > 0 | ||
| 488 | ? finiteNonNegative((ioWriteDelta * 1_000) / elapsedMs) | ||
| 489 | : 0, | ||
| 490 | ioSemantics: process.ioSemantics, | ||
| 491 | ...(electronMetric ? { idleWakeupsPerSecond: electronMetric.idleWakeupsPerSecond } : {}), | ||
| 492 | runTimeMs: process.runTimeMs, | ||
| 493 | firstSeenAt, | ||
| 494 | lastSeenAt: DateTime.makeUnsafe(sampledAtMs), | ||
| 495 | }; | ||
| 496 | nextPrevious.set(identityKey, { | ||
| 497 | process: normalizedProcess, | ||
| 498 | sampledAtMs, | ||
| 499 | }); | ||
| 500 | processDeltas.push({ | ||
| 501 | identityKey, | ||
| 502 | category, | ||
| 503 | cpuTimeMs: cpuTimeDelta, | ||
| 504 | ioReadBytes: ioReadDelta, | ||
| 505 | ioWriteBytes: ioWriteDelta, | ||
| 506 | }); | ||
| 507 | return normalizedProcess; | ||
| 508 | }); | ||
| 509 | const ordered = orderProcessTree(normalized, rootPids); | ||
| 510 | |||
| 511 | const counters = input.updatePrevious | ||
| 512 | ? applyLifecycleCounters({ | ||
| 513 | counters: input.counters, | ||
| 514 | deltas: processDeltas, | ||
| 515 | current: nextPrevious, | ||
| 516 | previous: input.previous, | ||
| 517 | }) | ||
| 518 | : input.counters; | ||
| 519 | const backendProcesses = ordered.filter( | ||
| 520 | (process) => categoryGroup(process.category) === "backend", | ||
| 521 | ); | ||
| 522 | const electronProcesses = ordered.filter( | ||
| 523 | (process) => categoryGroup(process.category) === "electron", | ||
| 524 | ); | ||
| 525 | const monitorProcesses = ordered.filter( | ||
| 526 | (process) => categoryGroup(process.category) === "monitor", | ||
| 527 | ); | ||
| 528 | |||
| 529 | return { | ||
| 530 | sampledAtMs, | ||
| 531 | processes: ordered, | ||
| 532 | previous: input.updatePrevious ? nextPrevious : input.previous, | ||
| 533 | counters, | ||
| 534 | groups: { | ||
| 535 | backend: aggregate(backendProcesses, counters.backend), | ||
| 536 | electron: aggregate(electronProcesses, counters.electron), | ||
| 537 | monitor: aggregate(monitorProcesses, counters.monitor), | ||
| 538 | allT3: aggregate(ordered, counters.allT3), | ||
| 539 | }, | ||
| 540 | deltas: processDeltas, | ||
| 541 | }; | ||
| 542 | } | ||
| @@ -0,0 +1,656 @@ | |||
| 1 | import type { | ||
| 2 | ResourceMonitorCapabilities, | ||
| 3 | ResourceMonitorCommand, | ||
| 4 | ResourceMonitorEvent, | ||
| 5 | ResourceMonitorExternalProcess, | ||
| 6 | ResourceMonitorHelloEvent, | ||
| 7 | ResourceMonitorSnapshotEvent, | ||
| 8 | ResourceTelemetrySourceStatus, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { | ||
| 11 | RESOURCE_MONITOR_PROTOCOL_VERSION, | ||
| 12 | ResourceMonitorCommand as ResourceMonitorCommandSchema, | ||
| 13 | ResourceMonitorEvent as ResourceMonitorEventSchema, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import * as Cause from "effect/Cause"; | ||
| 16 | import * as Context from "effect/Context"; | ||
| 17 | import * as Crypto from "effect/Crypto"; | ||
| 18 | import * as DateTime from "effect/DateTime"; | ||
| 19 | import * as Deferred from "effect/Deferred"; | ||
| 20 | import * as Duration from "effect/Duration"; | ||
| 21 | import * as Effect from "effect/Effect"; | ||
| 22 | import * as Fiber from "effect/Fiber"; | ||
| 23 | import * as Layer from "effect/Layer"; | ||
| 24 | import * as Option from "effect/Option"; | ||
| 25 | import * as PubSub from "effect/PubSub"; | ||
| 26 | import * as Queue from "effect/Queue"; | ||
| 27 | import * as Ref from "effect/Ref"; | ||
| 28 | import * as Result from "effect/Result"; | ||
| 29 | import * as Schema from "effect/Schema"; | ||
| 30 | import * as Semaphore from "effect/Semaphore"; | ||
| 31 | import * as Stream from "effect/Stream"; | ||
| 32 | import * as Ndjson from "effect/unstable/encoding/Ndjson"; | ||
| 33 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 34 | |||
| 35 | import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; | ||
| 36 | import { ServerConfig } from "../config.ts"; | ||
| 37 | |||
| 38 | const SAMPLE_INTERVAL_MS = 1_000; | ||
| 39 | const HANDSHAKE_TIMEOUT = Duration.seconds(5); | ||
| 40 | const SAMPLE_REQUEST_TIMEOUT = Duration.seconds(5); | ||
| 41 | const INITIAL_RESTART_DELAY = Duration.millis(500); | ||
| 42 | const MAX_RESTART_DELAY = Duration.seconds(10); | ||
| 43 | const FAILURE_WINDOW_MS = 60_000; | ||
| 44 | const MAX_FAILURES_PER_WINDOW = 5; | ||
| 45 | |||
| 46 | export class NativeTelemetrySpawnFailed extends Schema.TaggedErrorClass<NativeTelemetrySpawnFailed>()( | ||
| 47 | "NativeTelemetrySpawnFailed", | ||
| 48 | { | ||
| 49 | path: Schema.String, | ||
| 50 | cause: Schema.Defect(), | ||
| 51 | }, | ||
| 52 | ) { | ||
| 53 | override get message(): string { | ||
| 54 | return `Failed to start resource monitor '${this.path}'.`; | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | export class NativeTelemetryHandshakeTimedOut extends Schema.TaggedErrorClass<NativeTelemetryHandshakeTimedOut>()( | ||
| 59 | "NativeTelemetryHandshakeTimedOut", | ||
| 60 | { | ||
| 61 | timeoutMs: Schema.Number, | ||
| 62 | }, | ||
| 63 | ) { | ||
| 64 | override get message(): string { | ||
| 65 | return `Resource monitor handshake timed out after ${this.timeoutMs}ms.`; | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | export class NativeTelemetryProtocolMismatch extends Schema.TaggedErrorClass<NativeTelemetryProtocolMismatch>()( | ||
| 70 | "NativeTelemetryProtocolMismatch", | ||
| 71 | { | ||
| 72 | expectedVersion: Schema.Number, | ||
| 73 | receivedVersion: Schema.Number, | ||
| 74 | }, | ||
| 75 | ) { | ||
| 76 | override get message(): string { | ||
| 77 | return `Resource monitor protocol ${this.receivedVersion} is incompatible with expected protocol ${this.expectedVersion}.`; | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | export class NativeTelemetryDecodeFailed extends Schema.TaggedErrorClass<NativeTelemetryDecodeFailed>()( | ||
| 82 | "NativeTelemetryDecodeFailed", | ||
| 83 | { | ||
| 84 | cause: Schema.Defect(), | ||
| 85 | }, | ||
| 86 | ) { | ||
| 87 | override get message(): string { | ||
| 88 | return "Failed to decode resource monitor output."; | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | export class NativeTelemetryCommandFailed extends Schema.TaggedErrorClass<NativeTelemetryCommandFailed>()( | ||
| 93 | "NativeTelemetryCommandFailed", | ||
| 94 | { | ||
| 95 | operation: Schema.String, | ||
| 96 | cause: Schema.Defect(), | ||
| 97 | }, | ||
| 98 | ) { | ||
| 99 | override get message(): string { | ||
| 100 | return `Resource monitor command '${this.operation}' failed.`; | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | export class NativeTelemetryExited extends Schema.TaggedErrorClass<NativeTelemetryExited>()( | ||
| 105 | "NativeTelemetryExited", | ||
| 106 | { | ||
| 107 | exitCode: Schema.Number, | ||
| 108 | }, | ||
| 109 | ) { | ||
| 110 | override get message(): string { | ||
| 111 | return `Resource monitor exited with code ${this.exitCode}.`; | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | export class NativeTelemetryUnavailable extends Schema.TaggedErrorClass<NativeTelemetryUnavailable>()( | ||
| 116 | "NativeTelemetryUnavailable", | ||
| 117 | { | ||
| 118 | reason: Schema.String, | ||
| 119 | }, | ||
| 120 | ) { | ||
| 121 | override get message(): string { | ||
| 122 | return `Resource monitor is unavailable: ${this.reason}`; | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | export type NativeTelemetryClientError = | ||
| 127 | | NativeTelemetrySpawnFailed | ||
| 128 | | NativeTelemetryHandshakeTimedOut | ||
| 129 | | NativeTelemetryProtocolMismatch | ||
| 130 | | NativeTelemetryDecodeFailed | ||
| 131 | | NativeTelemetryCommandFailed | ||
| 132 | | NativeTelemetryExited | ||
| 133 | | NativeTelemetryUnavailable; | ||
| 134 | |||
| 135 | export interface NativeTelemetryClientHealth { | ||
| 136 | readonly status: ResourceTelemetrySourceStatus; | ||
| 137 | readonly hello: Option.Option<ResourceMonitorHelloEvent>; | ||
| 138 | readonly lastSampleAt: Option.Option<DateTime.Utc>; | ||
| 139 | readonly lastError: Option.Option<string>; | ||
| 140 | readonly restartCount: number; | ||
| 141 | } | ||
| 142 | |||
| 143 | export interface NativeTelemetryClientShape { | ||
| 144 | readonly capabilities: Effect.Effect<ResourceMonitorCapabilities, NativeTelemetryClientError>; | ||
| 145 | readonly snapshots: Stream.Stream<ResourceMonitorSnapshotEvent, NativeTelemetryClientError>; | ||
| 146 | readonly setExternalProcesses: ( | ||
| 147 | processes: ReadonlyArray<ResourceMonitorExternalProcess>, | ||
| 148 | ) => Effect.Effect<void, NativeTelemetryClientError>; | ||
| 149 | readonly sampleNow: Effect.Effect<ResourceMonitorSnapshotEvent, NativeTelemetryClientError>; | ||
| 150 | readonly retry: Effect.Effect<boolean>; | ||
| 151 | readonly health: Effect.Effect<NativeTelemetryClientHealth>; | ||
| 152 | readonly healthChanges: Stream.Stream<NativeTelemetryClientHealth>; | ||
| 153 | } | ||
| 154 | |||
| 155 | export class NativeTelemetryClient extends Context.Service< | ||
| 156 | NativeTelemetryClient, | ||
| 157 | NativeTelemetryClientShape | ||
| 158 | >()("t3/resourceTelemetry/NativeTelemetryClient") {} | ||
| 159 | |||
| 160 | interface ClientState { | ||
| 161 | readonly status: ResourceTelemetrySourceStatus; | ||
| 162 | readonly handle: Option.Option<ChildProcessSpawner.ChildProcessHandle>; | ||
| 163 | readonly hello: Option.Option<ResourceMonitorHelloEvent>; | ||
| 164 | readonly lastSampleAt: Option.Option<DateTime.Utc>; | ||
| 165 | readonly lastError: Option.Option<string>; | ||
| 166 | readonly restartCount: number; | ||
| 167 | } | ||
| 168 | |||
| 169 | const initialState: ClientState = { | ||
| 170 | status: "starting", | ||
| 171 | handle: Option.none(), | ||
| 172 | hello: Option.none(), | ||
| 173 | lastSampleAt: Option.none(), | ||
| 174 | lastError: Option.none(), | ||
| 175 | restartCount: 0, | ||
| 176 | }; | ||
| 177 | |||
| 178 | function toHealth(state: ClientState): NativeTelemetryClientHealth { | ||
| 179 | return { | ||
| 180 | status: state.status, | ||
| 181 | hello: state.hello, | ||
| 182 | lastSampleAt: state.lastSampleAt, | ||
| 183 | lastError: state.lastError, | ||
| 184 | restartCount: state.restartCount, | ||
| 185 | }; | ||
| 186 | } | ||
| 187 | |||
| 188 | const decodeMonitorEvent: ( | ||
| 189 | value: unknown, | ||
| 190 | ) => Effect.Effect<ResourceMonitorEvent, Schema.SchemaError> = Schema.decodeUnknownEffect( | ||
| 191 | ResourceMonitorEventSchema, | ||
| 192 | ); | ||
| 193 | const encodeMonitorCommand = Schema.encodeEffect( | ||
| 194 | Schema.fromJsonString(ResourceMonitorCommandSchema), | ||
| 195 | ); | ||
| 196 | const isProtocolMismatch = Schema.is(NativeTelemetryProtocolMismatch); | ||
| 197 | const isDecodeFailed = Schema.is(NativeTelemetryDecodeFailed); | ||
| 198 | const isCommandFailed = Schema.is(NativeTelemetryCommandFailed); | ||
| 199 | |||
| 200 | function eventVersion(value: unknown): number | undefined { | ||
| 201 | if (typeof value !== "object" || value === null) return undefined; | ||
| 202 | const version = Reflect.get(value, "version"); | ||
| 203 | return typeof version === "number" ? version : undefined; | ||
| 204 | } | ||
| 205 | |||
| 206 | function restartDelay(attempt: number): Duration.Duration { | ||
| 207 | return Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY); | ||
| 208 | } | ||
| 209 | |||
| 210 | function errorMessage(error: NativeTelemetryClientError): string { | ||
| 211 | return error.message; | ||
| 212 | } | ||
| 213 | |||
| 214 | export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(function* () { | ||
| 215 | const binary = yield* ResourceMonitorBinary.ResourceMonitorBinary; | ||
| 216 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 217 | const crypto = yield* Crypto.Crypto; | ||
| 218 | const config = yield* ServerConfig; | ||
| 219 | const state = yield* Ref.make(initialState); | ||
| 220 | const externalProcesses = yield* Ref.make<ReadonlyArray<ResourceMonitorExternalProcess>>([]); | ||
| 221 | const pendingSamples = yield* Ref.make( | ||
| 222 | new Map<string, Deferred.Deferred<ResourceMonitorSnapshotEvent, NativeTelemetryClientError>>(), | ||
| 223 | ); | ||
| 224 | const snapshots = yield* PubSub.sliding<ResourceMonitorSnapshotEvent>(8); | ||
| 225 | const healthChanges = yield* PubSub.sliding<NativeTelemetryClientHealth>(4); | ||
| 226 | const retryQueue = yield* Queue.sliding<void>(1); | ||
| 227 | const commandMutex = yield* Semaphore.make(1); | ||
| 228 | const publishHealth = Ref.get(state).pipe( | ||
| 229 | Effect.map(toHealth), | ||
| 230 | Effect.flatMap((health) => PubSub.publish(healthChanges, health)), | ||
| 231 | Effect.asVoid, | ||
| 232 | ); | ||
| 233 | |||
| 234 | const failPending = (error: NativeTelemetryClientError) => | ||
| 235 | Ref.getAndSet(pendingSamples, new Map()).pipe( | ||
| 236 | Effect.flatMap((pending) => | ||
| 237 | Effect.forEach(pending.values(), (deferred) => Deferred.fail(deferred, error), { | ||
| 238 | discard: true, | ||
| 239 | }), | ||
| 240 | ), | ||
| 241 | ); | ||
| 242 | |||
| 243 | const writeCommand = ( | ||
| 244 | handle: ChildProcessSpawner.ChildProcessHandle, | ||
| 245 | command: ResourceMonitorCommand, | ||
| 246 | ): Effect.Effect<void, NativeTelemetryClientError> => | ||
| 247 | commandMutex.withPermits(1)( | ||
| 248 | encodeMonitorCommand(command).pipe( | ||
| 249 | Effect.map((encoded) => `${encoded}\n`), | ||
| 250 | Effect.mapError( | ||
| 251 | (cause) => | ||
| 252 | new NativeTelemetryCommandFailed({ | ||
| 253 | operation: command.type, | ||
| 254 | cause, | ||
| 255 | }), | ||
| 256 | ), | ||
| 257 | Effect.flatMap((encoded) => | ||
| 258 | Stream.run(Stream.encodeText(Stream.make(encoded)), handle.stdin), | ||
| 259 | ), | ||
| 260 | Effect.mapError( | ||
| 261 | (cause) => | ||
| 262 | new NativeTelemetryCommandFailed({ | ||
| 263 | operation: command.type, | ||
| 264 | cause, | ||
| 265 | }), | ||
| 266 | ), | ||
| 267 | ), | ||
| 268 | ); | ||
| 269 | |||
| 270 | const processEvent = ( | ||
| 271 | event: ResourceMonitorEvent, | ||
| 272 | helloDeferred: Deferred.Deferred<ResourceMonitorHelloEvent>, | ||
| 273 | ): Effect.Effect<void, NativeTelemetryClientError> => { | ||
| 274 | switch (event.type) { | ||
| 275 | case "hello": | ||
| 276 | return Ref.update(state, (current) => ({ | ||
| 277 | ...current, | ||
| 278 | status: "healthy" as const, | ||
| 279 | hello: Option.some(event), | ||
| 280 | lastError: Option.none(), | ||
| 281 | })).pipe( | ||
| 282 | Effect.andThen(publishHealth), | ||
| 283 | Effect.andThen(Deferred.succeed(helloDeferred, event)), | ||
| 284 | Effect.asVoid, | ||
| 285 | ); | ||
| 286 | case "snapshot": | ||
| 287 | return Effect.gen(function* () { | ||
| 288 | const sampledAt = DateTime.makeUnsafe(event.sampledAtUnixMs); | ||
| 289 | yield* Ref.update(state, (current) => ({ | ||
| 290 | ...current, | ||
| 291 | status: "healthy" as const, | ||
| 292 | lastSampleAt: Option.some(sampledAt), | ||
| 293 | lastError: Option.none(), | ||
| 294 | })); | ||
| 295 | yield* PubSub.publish(snapshots, event); | ||
| 296 | if (event.requestId) { | ||
| 297 | const deferred = yield* Ref.modify(pendingSamples, (pending) => { | ||
| 298 | const next = new Map(pending); | ||
| 299 | const current = next.get(event.requestId!); | ||
| 300 | next.delete(event.requestId!); | ||
| 301 | return [Option.fromUndefinedOr(current), next]; | ||
| 302 | }); | ||
| 303 | if (Option.isSome(deferred)) { | ||
| 304 | yield* Deferred.succeed(deferred.value, event); | ||
| 305 | } | ||
| 306 | } | ||
| 307 | }); | ||
| 308 | case "error": | ||
| 309 | return Ref.update(state, (current) => ({ | ||
| 310 | ...current, | ||
| 311 | status: "degraded" as const, | ||
| 312 | lastError: Option.some(event.message), | ||
| 313 | })).pipe( | ||
| 314 | Effect.andThen(publishHealth), | ||
| 315 | Effect.andThen( | ||
| 316 | event.recoverable | ||
| 317 | ? Effect.void | ||
| 318 | : Effect.fail( | ||
| 319 | new NativeTelemetryCommandFailed({ | ||
| 320 | operation: event.code, | ||
| 321 | cause: event.message, | ||
| 322 | }), | ||
| 323 | ), | ||
| 324 | ), | ||
| 325 | ); | ||
| 326 | } | ||
| 327 | }; | ||
| 328 | |||
| 329 | const runAttempt: Effect.Effect<void, NativeTelemetryClientError> = Effect.scoped( | ||
| 330 | Effect.gen(function* () { | ||
| 331 | const executablePath = yield* binary.resolve.pipe( | ||
| 332 | Effect.mapError( | ||
| 333 | (error) => | ||
| 334 | new NativeTelemetryUnavailable({ | ||
| 335 | reason: error.message, | ||
| 336 | }), | ||
| 337 | ), | ||
| 338 | ); | ||
| 339 | const command = ChildProcess.make(executablePath, [], { | ||
| 340 | cwd: config.cwd, | ||
| 341 | stdin: { | ||
| 342 | stream: "pipe", | ||
| 343 | endOnDone: false, | ||
| 344 | }, | ||
| 345 | stdout: "pipe", | ||
| 346 | stderr: "pipe", | ||
| 347 | killSignal: "SIGTERM", | ||
| 348 | forceKillAfter: Duration.seconds(2), | ||
| 349 | }); | ||
| 350 | const handle = yield* Effect.acquireRelease( | ||
| 351 | spawner | ||
| 352 | .spawn(command) | ||
| 353 | .pipe( | ||
| 354 | Effect.mapError( | ||
| 355 | (cause) => new NativeTelemetrySpawnFailed({ path: executablePath, cause }), | ||
| 356 | ), | ||
| 357 | ), | ||
| 358 | (child) => child.kill().pipe(Effect.ignore), | ||
| 359 | ); | ||
| 360 | yield* Ref.update(state, (current) => ({ | ||
| 361 | ...current, | ||
| 362 | status: "starting" as const, | ||
| 363 | handle: Option.some(handle), | ||
| 364 | hello: Option.none(), | ||
| 365 | })); | ||
| 366 | yield* publishHealth; | ||
| 367 | |||
| 368 | const helloDeferred = yield* Deferred.make<ResourceMonitorHelloEvent>(); | ||
| 369 | const eventFiber = yield* handle.stdout.pipe( | ||
| 370 | Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })), | ||
| 371 | Stream.mapEffect( | ||
| 372 | ( | ||
| 373 | value, | ||
| 374 | ): Effect.Effect< | ||
| 375 | ResourceMonitorEvent, | ||
| 376 | NativeTelemetryProtocolMismatch | NativeTelemetryDecodeFailed | ||
| 377 | > => { | ||
| 378 | const version = eventVersion(value); | ||
| 379 | if (version !== undefined && version !== RESOURCE_MONITOR_PROTOCOL_VERSION) { | ||
| 380 | return Effect.fail( | ||
| 381 | new NativeTelemetryProtocolMismatch({ | ||
| 382 | expectedVersion: RESOURCE_MONITOR_PROTOCOL_VERSION, | ||
| 383 | receivedVersion: version, | ||
| 384 | }), | ||
| 385 | ); | ||
| 386 | } | ||
| 387 | return decodeMonitorEvent(value).pipe( | ||
| 388 | Effect.mapError((cause) => new NativeTelemetryDecodeFailed({ cause })), | ||
| 389 | ); | ||
| 390 | }, | ||
| 391 | ), | ||
| 392 | Stream.runForEach((event) => processEvent(event, helloDeferred)), | ||
| 393 | Effect.mapError((cause) => | ||
| 394 | isProtocolMismatch(cause) || isDecodeFailed(cause) || isCommandFailed(cause) | ||
| 395 | ? cause | ||
| 396 | : new NativeTelemetryDecodeFailed({ cause }), | ||
| 397 | ), | ||
| 398 | Effect.forkScoped, | ||
| 399 | ); | ||
| 400 | yield* handle.stderr.pipe(Stream.runDrain, Effect.ignore, Effect.forkScoped); | ||
| 401 | |||
| 402 | const hello = yield* Deferred.await(helloDeferred).pipe( | ||
| 403 | Effect.timeoutOption(HANDSHAKE_TIMEOUT), | ||
| 404 | Effect.flatMap( | ||
| 405 | Option.match({ | ||
| 406 | onNone: () => | ||
| 407 | Effect.fail( | ||
| 408 | new NativeTelemetryHandshakeTimedOut({ | ||
| 409 | timeoutMs: Duration.toMillis(HANDSHAKE_TIMEOUT), | ||
| 410 | }), | ||
| 411 | ), | ||
| 412 | onSome: Effect.succeed, | ||
| 413 | }), | ||
| 414 | ), | ||
| 415 | ); | ||
| 416 | yield* writeCommand(handle, { | ||
| 417 | version: RESOURCE_MONITOR_PROTOCOL_VERSION, | ||
| 418 | type: "configure", | ||
| 419 | rootPid: process.pid, | ||
| 420 | sampleIntervalMs: SAMPLE_INTERVAL_MS, | ||
| 421 | externalProcesses: [...(yield* Ref.get(externalProcesses))], | ||
| 422 | }); | ||
| 423 | yield* Ref.update(state, (current) => ({ | ||
| 424 | ...current, | ||
| 425 | status: "healthy" as const, | ||
| 426 | hello: Option.some(hello), | ||
| 427 | })); | ||
| 428 | |||
| 429 | const exitEffect = handle.exitCode.pipe( | ||
| 430 | Effect.mapError( | ||
| 431 | (cause) => | ||
| 432 | new NativeTelemetryCommandFailed({ | ||
| 433 | operation: "waitForExit", | ||
| 434 | cause, | ||
| 435 | }), | ||
| 436 | ), | ||
| 437 | Effect.flatMap((exitCode) => | ||
| 438 | Effect.fail(new NativeTelemetryExited({ exitCode: Number(exitCode) })), | ||
| 439 | ), | ||
| 440 | ); | ||
| 441 | const decoderEffect = Fiber.join(eventFiber).pipe( | ||
| 442 | Effect.andThen(Effect.fail(new NativeTelemetryExited({ exitCode: -1 }))), | ||
| 443 | ); | ||
| 444 | return yield* Effect.raceFirst(exitEffect, decoderEffect); | ||
| 445 | }), | ||
| 446 | ).pipe( | ||
| 447 | Effect.ensuring( | ||
| 448 | Ref.update(state, (current) => ({ | ||
| 449 | ...current, | ||
| 450 | handle: Option.none(), | ||
| 451 | })), | ||
| 452 | ), | ||
| 453 | ); | ||
| 454 | |||
| 455 | yield* Effect.gen(function* () { | ||
| 456 | let failures: ReadonlyArray<number> = []; | ||
| 457 | let restartAttempt = 0; | ||
| 458 | |||
| 459 | while (true) { | ||
| 460 | const result = yield* Effect.result(runAttempt); | ||
| 461 | if (Result.isSuccess(result)) { | ||
| 462 | return; | ||
| 463 | } | ||
| 464 | |||
| 465 | const error = result.failure; | ||
| 466 | const now = DateTime.toEpochMillis(yield* DateTime.now); | ||
| 467 | failures = [...failures.filter((failedAt) => now - failedAt <= FAILURE_WINDOW_MS), now]; | ||
| 468 | const exhausted = failures.length >= MAX_FAILURES_PER_WINDOW; | ||
| 469 | yield* Ref.update(state, (current) => ({ | ||
| 470 | ...current, | ||
| 471 | status: exhausted ? ("unavailable" as const) : ("degraded" as const), | ||
| 472 | hello: Option.none(), | ||
| 473 | lastError: Option.some(errorMessage(error)), | ||
| 474 | restartCount: current.restartCount + 1, | ||
| 475 | })); | ||
| 476 | yield* publishHealth; | ||
| 477 | yield* failPending(error); | ||
| 478 | |||
| 479 | if (exhausted) { | ||
| 480 | yield* Queue.take(retryQueue); | ||
| 481 | failures = []; | ||
| 482 | restartAttempt = 0; | ||
| 483 | yield* Ref.update(state, (current) => ({ | ||
| 484 | ...current, | ||
| 485 | status: "starting" as const, | ||
| 486 | hello: Option.none(), | ||
| 487 | lastError: Option.none(), | ||
| 488 | })); | ||
| 489 | yield* publishHealth; | ||
| 490 | continue; | ||
| 491 | } | ||
| 492 | |||
| 493 | const manuallyRetried = yield* Effect.raceFirst( | ||
| 494 | Effect.sleep(restartDelay(restartAttempt)).pipe(Effect.as(false)), | ||
| 495 | Queue.take(retryQueue).pipe(Effect.as(true)), | ||
| 496 | ); | ||
| 497 | restartAttempt = manuallyRetried ? 0 : restartAttempt + 1; | ||
| 498 | } | ||
| 499 | }).pipe( | ||
| 500 | Effect.catchCause((cause) => | ||
| 501 | Cause.hasInterruptsOnly(cause) | ||
| 502 | ? Effect.void | ||
| 503 | : Ref.update(state, (current) => ({ | ||
| 504 | ...current, | ||
| 505 | status: "unavailable" as const, | ||
| 506 | hello: Option.none(), | ||
| 507 | lastError: Option.some(Cause.pretty(cause)), | ||
| 508 | })).pipe( | ||
| 509 | Effect.andThen(publishHealth), | ||
| 510 | Effect.andThen( | ||
| 511 | Effect.logWarning("Resource monitor supervisor failed", { | ||
| 512 | cause: Cause.pretty(cause), | ||
| 513 | }), | ||
| 514 | ), | ||
| 515 | ), | ||
| 516 | ), | ||
| 517 | Effect.forkScoped, | ||
| 518 | ); | ||
| 519 | |||
| 520 | const setExternalProcesses: NativeTelemetryClientShape["setExternalProcesses"] = (processes) => | ||
| 521 | Effect.gen(function* () { | ||
| 522 | yield* Ref.set(externalProcesses, [...processes]); | ||
| 523 | const current = yield* Ref.get(state); | ||
| 524 | if (Option.isNone(current.handle)) return; | ||
| 525 | yield* writeCommand(current.handle.value, { | ||
| 526 | version: RESOURCE_MONITOR_PROTOCOL_VERSION, | ||
| 527 | type: "setExternalProcesses", | ||
| 528 | processes: [...processes], | ||
| 529 | }); | ||
| 530 | }); | ||
| 531 | |||
| 532 | const sampleNow: NativeTelemetryClientShape["sampleNow"] = Effect.gen(function* () { | ||
| 533 | const current = yield* Ref.get(state); | ||
| 534 | if (Option.isNone(current.handle)) { | ||
| 535 | return yield* new NativeTelemetryUnavailable({ | ||
| 536 | reason: Option.getOrElse(current.lastError, () => "sidecar is not running"), | ||
| 537 | }); | ||
| 538 | } | ||
| 539 | |||
| 540 | const requestId = yield* crypto.randomUUIDv4.pipe( | ||
| 541 | Effect.mapError( | ||
| 542 | (cause) => | ||
| 543 | new NativeTelemetryCommandFailed({ | ||
| 544 | operation: "createRequestId", | ||
| 545 | cause, | ||
| 546 | }), | ||
| 547 | ), | ||
| 548 | ); | ||
| 549 | const deferred = yield* Deferred.make< | ||
| 550 | ResourceMonitorSnapshotEvent, | ||
| 551 | NativeTelemetryClientError | ||
| 552 | >(); | ||
| 553 | yield* Ref.update(pendingSamples, (pending) => { | ||
| 554 | const next = new Map(pending); | ||
| 555 | next.set(requestId, deferred); | ||
| 556 | return next; | ||
| 557 | }); | ||
| 558 | return yield* writeCommand(current.handle.value, { | ||
| 559 | version: RESOURCE_MONITOR_PROTOCOL_VERSION, | ||
| 560 | type: "sampleNow", | ||
| 561 | requestId, | ||
| 562 | }).pipe( | ||
| 563 | Effect.andThen( | ||
| 564 | Deferred.await(deferred).pipe( | ||
| 565 | Effect.timeoutOption(SAMPLE_REQUEST_TIMEOUT), | ||
| 566 | Effect.flatMap( | ||
| 567 | Option.match({ | ||
| 568 | onNone: () => | ||
| 569 | Effect.fail( | ||
| 570 | new NativeTelemetryCommandFailed({ | ||
| 571 | operation: "sampleNow", | ||
| 572 | cause: "sample request timed out", | ||
| 573 | }), | ||
| 574 | ), | ||
| 575 | onSome: Effect.succeed, | ||
| 576 | }), | ||
| 577 | ), | ||
| 578 | ), | ||
| 579 | ), | ||
| 580 | Effect.ensuring( | ||
| 581 | Ref.update(pendingSamples, (pending) => { | ||
| 582 | const next = new Map(pending); | ||
| 583 | next.delete(requestId); | ||
| 584 | return next; | ||
| 585 | }), | ||
| 586 | ), | ||
| 587 | ); | ||
| 588 | }); | ||
| 589 | |||
| 590 | const health = Ref.get(state).pipe(Effect.map(toHealth)); | ||
| 591 | |||
| 592 | return NativeTelemetryClient.of({ | ||
| 593 | capabilities: Ref.get(state).pipe( | ||
| 594 | Effect.flatMap((current) => | ||
| 595 | Option.match(current.hello, { | ||
| 596 | onNone: () => | ||
| 597 | Effect.fail( | ||
| 598 | new NativeTelemetryUnavailable({ | ||
| 599 | reason: Option.getOrElse(current.lastError, () => "handshake is incomplete"), | ||
| 600 | }), | ||
| 601 | ), | ||
| 602 | onSome: (hello) => Effect.succeed(hello.capabilities), | ||
| 603 | }), | ||
| 604 | ), | ||
| 605 | ), | ||
| 606 | snapshots: Stream.fromPubSub(snapshots), | ||
| 607 | setExternalProcesses, | ||
| 608 | sampleNow, | ||
| 609 | retry: Ref.get(state).pipe( | ||
| 610 | Effect.flatMap((current) => | ||
| 611 | current.status === "healthy" || current.status === "starting" | ||
| 612 | ? Effect.succeed(false) | ||
| 613 | : Queue.offer(retryQueue, undefined).pipe(Effect.as(true)), | ||
| 614 | ), | ||
| 615 | ), | ||
| 616 | health, | ||
| 617 | healthChanges: Stream.fromPubSub(healthChanges), | ||
| 618 | }); | ||
| 619 | }); | ||
| 620 | |||
| 621 | export const layer = Layer.effect(NativeTelemetryClient, make()); | ||
| 622 | |||
| 623 | export const layerTest = ( | ||
| 624 | overrides: Partial<NativeTelemetryClientShape> = {}, | ||
| 625 | ): Layer.Layer<NativeTelemetryClient> => | ||
| 626 | Layer.succeed( | ||
| 627 | NativeTelemetryClient, | ||
| 628 | NativeTelemetryClient.of({ | ||
| 629 | capabilities: Effect.succeed({ | ||
| 630 | cumulativeCpuTime: true, | ||
| 631 | currentCpuPercent: true, | ||
| 632 | residentMemory: true, | ||
| 633 | virtualMemory: true, | ||
| 634 | ioBytes: true, | ||
| 635 | processStartTime: true, | ||
| 636 | processTree: true, | ||
| 637 | }), | ||
| 638 | snapshots: Stream.empty, | ||
| 639 | setExternalProcesses: () => Effect.void, | ||
| 640 | sampleNow: Effect.fail( | ||
| 641 | new NativeTelemetryUnavailable({ | ||
| 642 | reason: "No resource monitor sample was configured for this test.", | ||
| 643 | }), | ||
| 644 | ), | ||
| 645 | retry: Effect.succeed(false), | ||
| 646 | health: Effect.succeed({ | ||
| 647 | status: "unavailable", | ||
| 648 | hello: Option.none(), | ||
| 649 | lastSampleAt: Option.none(), | ||
| 650 | lastError: Option.some("Resource monitor test implementation is unavailable."), | ||
| 651 | restartCount: 0, | ||
| 652 | }), | ||
| 653 | healthChanges: Stream.empty, | ||
| 654 | ...overrides, | ||
| 655 | }), | ||
| 656 | ); | ||
| @@ -0,0 +1,73 @@ | |||
| 1 | import type { ResourceAttributionEntry, ResourceAttributionSnapshot } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Ref from "effect/Ref"; | ||
| 7 | |||
| 8 | export interface ResourceAttributionRecord { | ||
| 9 | readonly component: string; | ||
| 10 | readonly operation: string; | ||
| 11 | readonly logicalReadBytes?: number; | ||
| 12 | readonly logicalWriteBytes?: number; | ||
| 13 | readonly count?: number; | ||
| 14 | readonly durationMs?: number; | ||
| 15 | } | ||
| 16 | |||
| 17 | export interface ResourceAttributionShape { | ||
| 18 | readonly record: (input: ResourceAttributionRecord) => Effect.Effect<void>; | ||
| 19 | readonly snapshot: Effect.Effect<ResourceAttributionSnapshot>; | ||
| 20 | } | ||
| 21 | |||
| 22 | export class ResourceAttribution extends Context.Service< | ||
| 23 | ResourceAttribution, | ||
| 24 | ResourceAttributionShape | ||
| 25 | >()("t3/resourceTelemetry/ResourceAttribution") {} | ||
| 26 | |||
| 27 | function key(input: Pick<ResourceAttributionRecord, "component" | "operation">): string { | ||
| 28 | return `${input.component}\u0000${input.operation}`; | ||
| 29 | } | ||
| 30 | |||
| 31 | function nonNegativeInteger(value: number | undefined, fallback: number): number { | ||
| 32 | if (value === undefined) return fallback; | ||
| 33 | if (!Number.isFinite(value)) return 0; | ||
| 34 | return Math.max(0, Math.round(value)); | ||
| 35 | } | ||
| 36 | |||
| 37 | export const make = Effect.fn("resourceTelemetry.resourceAttribution.make")(function* () { | ||
| 38 | const entries = yield* Ref.make(new Map<string, ResourceAttributionEntry>()); | ||
| 39 | |||
| 40 | const record: ResourceAttributionShape["record"] = (input) => | ||
| 41 | Ref.update(entries, (current) => { | ||
| 42 | const next = new Map(current); | ||
| 43 | const entryKey = key(input); | ||
| 44 | const existing = next.get(entryKey); | ||
| 45 | next.set(entryKey, { | ||
| 46 | component: input.component, | ||
| 47 | operation: input.operation, | ||
| 48 | logicalReadBytes: | ||
| 49 | (existing?.logicalReadBytes ?? 0) + nonNegativeInteger(input.logicalReadBytes, 0), | ||
| 50 | logicalWriteBytes: | ||
| 51 | (existing?.logicalWriteBytes ?? 0) + nonNegativeInteger(input.logicalWriteBytes, 0), | ||
| 52 | count: (existing?.count ?? 0) + nonNegativeInteger(input.count, 1), | ||
| 53 | durationMs: (existing?.durationMs ?? 0) + nonNegativeInteger(input.durationMs, 0), | ||
| 54 | }); | ||
| 55 | return next; | ||
| 56 | }); | ||
| 57 | |||
| 58 | return ResourceAttribution.of({ | ||
| 59 | record, | ||
| 60 | snapshot: Effect.gen(function* () { | ||
| 61 | const readAt = yield* DateTime.now; | ||
| 62 | const current = yield* Ref.get(entries); | ||
| 63 | return { | ||
| 64 | readAt, | ||
| 65 | entries: [...current.values()].toSorted( | ||
| 66 | (left, right) => right.logicalWriteBytes - left.logicalWriteBytes, | ||
| 67 | ), | ||
| 68 | }; | ||
| 69 | }), | ||
| 70 | }); | ||
| 71 | }); | ||
| 72 | |||
| 73 | export const layer = Layer.effect(ResourceAttribution, make()); | ||
| @@ -0,0 +1,80 @@ | |||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { | ||
| 3 | HostProcessArchitecture, | ||
| 4 | HostProcessEnvironment, | ||
| 5 | HostProcessPlatform, | ||
| 6 | } from "@t3tools/shared/hostProcess"; | ||
| 7 | import { assert, describe, it } from "@effect/vitest"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as FileSystem from "effect/FileSystem"; | ||
| 10 | |||
| 11 | import { ServerConfig } from "../config.ts"; | ||
| 12 | import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; | ||
| 13 | |||
| 14 | describe("ResourceMonitorBinary", () => { | ||
| 15 | it.effect("resolves an executable override", () => | ||
| 16 | Effect.gen(function* () { | ||
| 17 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 18 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 19 | prefix: "t3-resource-monitor-binary-", | ||
| 20 | }); | ||
| 21 | const binaryPath = `${baseDir}/t3-resource-monitor`; | ||
| 22 | yield* fileSystem.writeFileString(binaryPath, "binary"); | ||
| 23 | yield* fileSystem.chmod(binaryPath, 0o755); | ||
| 24 | |||
| 25 | const service = yield* ResourceMonitorBinary.make().pipe( | ||
| 26 | Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), | ||
| 27 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 28 | Effect.provideService(HostProcessArchitecture, "x64"), | ||
| 29 | Effect.provideService(HostProcessEnvironment, { | ||
| 30 | T3CODE_RESOURCE_MONITOR_PATH: binaryPath, | ||
| 31 | }), | ||
| 32 | ); | ||
| 33 | |||
| 34 | assert.equal(yield* service.resolve, binaryPath); | ||
| 35 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 36 | ); | ||
| 37 | |||
| 38 | it.effect("rejects a non-executable POSIX override", () => | ||
| 39 | Effect.gen(function* () { | ||
| 40 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 41 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 42 | prefix: "t3-resource-monitor-binary-", | ||
| 43 | }); | ||
| 44 | const binaryPath = `${baseDir}/t3-resource-monitor`; | ||
| 45 | yield* fileSystem.writeFileString(binaryPath, "binary"); | ||
| 46 | yield* fileSystem.chmod(binaryPath, 0o644); | ||
| 47 | |||
| 48 | const service = yield* ResourceMonitorBinary.make().pipe( | ||
| 49 | Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), | ||
| 50 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 51 | Effect.provideService(HostProcessArchitecture, "x64"), | ||
| 52 | Effect.provideService(HostProcessEnvironment, { | ||
| 53 | T3CODE_RESOURCE_MONITOR_PATH: binaryPath, | ||
| 54 | }), | ||
| 55 | ); | ||
| 56 | const error = yield* Effect.flip(service.resolve); | ||
| 57 | |||
| 58 | assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryNotExecutable); | ||
| 59 | assert.equal(error.path, binaryPath); | ||
| 60 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 61 | ); | ||
| 62 | |||
| 63 | it.effect("rejects unsupported platform and architecture pairs", () => | ||
| 64 | Effect.gen(function* () { | ||
| 65 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 66 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 67 | prefix: "t3-resource-monitor-binary-", | ||
| 68 | }); | ||
| 69 | const service = yield* ResourceMonitorBinary.make().pipe( | ||
| 70 | Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), | ||
| 71 | Effect.provideService(HostProcessPlatform, "freebsd"), | ||
| 72 | Effect.provideService(HostProcessArchitecture, "ia32"), | ||
| 73 | Effect.provideService(HostProcessEnvironment, {}), | ||
| 74 | ); | ||
| 75 | const error = yield* Effect.flip(service.resolve); | ||
| 76 | |||
| 77 | assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryUnsupported); | ||
| 78 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 79 | ); | ||
| 80 | }); | ||
| @@ -0,0 +1,193 @@ | |||
| 1 | import { | ||
| 2 | HostProcessArchitecture, | ||
| 3 | HostProcessEnvironment, | ||
| 4 | HostProcessPlatform, | ||
| 5 | } from "@t3tools/shared/hostProcess"; | ||
| 6 | import * as Context from "effect/Context"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as FileSystem from "effect/FileSystem"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | import * as Option from "effect/Option"; | ||
| 11 | import * as Path from "effect/Path"; | ||
| 12 | import * as Schema from "effect/Schema"; | ||
| 13 | |||
| 14 | import { ServerConfig } from "../config.ts"; | ||
| 15 | |||
| 16 | export class ResourceMonitorBinaryUnsupported extends Schema.TaggedErrorClass<ResourceMonitorBinaryUnsupported>()( | ||
| 17 | "ResourceMonitorBinaryUnsupported", | ||
| 18 | { | ||
| 19 | platform: Schema.String, | ||
| 20 | architecture: Schema.String, | ||
| 21 | }, | ||
| 22 | ) { | ||
| 23 | override get message(): string { | ||
| 24 | return `Resource monitoring is unsupported on ${this.platform}/${this.architecture}.`; | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | export class ResourceMonitorBinaryNotFound extends Schema.TaggedErrorClass<ResourceMonitorBinaryNotFound>()( | ||
| 29 | "ResourceMonitorBinaryNotFound", | ||
| 30 | { | ||
| 31 | platform: Schema.String, | ||
| 32 | architecture: Schema.String, | ||
| 33 | candidates: Schema.Array(Schema.String), | ||
| 34 | }, | ||
| 35 | ) { | ||
| 36 | override get message(): string { | ||
| 37 | return `Resource monitor binary was not found for ${this.platform}/${this.architecture}.`; | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | export class ResourceMonitorBinaryNotExecutable extends Schema.TaggedErrorClass<ResourceMonitorBinaryNotExecutable>()( | ||
| 42 | "ResourceMonitorBinaryNotExecutable", | ||
| 43 | { | ||
| 44 | path: Schema.String, | ||
| 45 | mode: Schema.Number, | ||
| 46 | }, | ||
| 47 | ) { | ||
| 48 | override get message(): string { | ||
| 49 | return `Resource monitor binary at '${this.path}' is not executable.`; | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | export type ResourceMonitorBinaryError = | ||
| 54 | | ResourceMonitorBinaryUnsupported | ||
| 55 | | ResourceMonitorBinaryNotFound | ||
| 56 | | ResourceMonitorBinaryNotExecutable; | ||
| 57 | |||
| 58 | export interface ResourceMonitorBinaryShape { | ||
| 59 | readonly resolve: Effect.Effect<string, ResourceMonitorBinaryError>; | ||
| 60 | } | ||
| 61 | |||
| 62 | export class ResourceMonitorBinary extends Context.Service< | ||
| 63 | ResourceMonitorBinary, | ||
| 64 | ResourceMonitorBinaryShape | ||
| 65 | >()("t3/resourceTelemetry/ResourceMonitorBinary") {} | ||
| 66 | |||
| 67 | function binaryName(platform: NodeJS.Platform): string { | ||
| 68 | return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor"; | ||
| 69 | } | ||
| 70 | |||
| 71 | export function resourceMonitorPlatformKey( | ||
| 72 | platform: NodeJS.Platform, | ||
| 73 | architecture: NodeJS.Architecture, | ||
| 74 | ): string | undefined { | ||
| 75 | if ( | ||
| 76 | (platform !== "darwin" && platform !== "linux" && platform !== "win32") || | ||
| 77 | (architecture !== "arm64" && architecture !== "x64") | ||
| 78 | ) { | ||
| 79 | return undefined; | ||
| 80 | } | ||
| 81 | return `${platform}-${architecture}`; | ||
| 82 | } | ||
| 83 | |||
| 84 | function resourceMonitorRustTarget( | ||
| 85 | platform: NodeJS.Platform, | ||
| 86 | architecture: NodeJS.Architecture, | ||
| 87 | ): string | undefined { | ||
| 88 | if (platform === "darwin") { | ||
| 89 | return architecture === "arm64" | ||
| 90 | ? "aarch64-apple-darwin" | ||
| 91 | : architecture === "x64" | ||
| 92 | ? "x86_64-apple-darwin" | ||
| 93 | : undefined; | ||
| 94 | } | ||
| 95 | if (platform === "linux") { | ||
| 96 | return architecture === "arm64" | ||
| 97 | ? "aarch64-unknown-linux-gnu" | ||
| 98 | : architecture === "x64" | ||
| 99 | ? "x86_64-unknown-linux-gnu" | ||
| 100 | : undefined; | ||
| 101 | } | ||
| 102 | if (platform === "win32") { | ||
| 103 | return architecture === "arm64" | ||
| 104 | ? "aarch64-pc-windows-msvc" | ||
| 105 | : architecture === "x64" | ||
| 106 | ? "x86_64-pc-windows-msvc" | ||
| 107 | : undefined; | ||
| 108 | } | ||
| 109 | return undefined; | ||
| 110 | } | ||
| 111 | |||
| 112 | export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(function* () { | ||
| 113 | const config = yield* ServerConfig; | ||
| 114 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 115 | const path = yield* Path.Path; | ||
| 116 | const platform = yield* HostProcessPlatform; | ||
| 117 | const architecture = yield* HostProcessArchitecture; | ||
| 118 | const environment = yield* HostProcessEnvironment; | ||
| 119 | const executableName = binaryName(platform); | ||
| 120 | const platformKey = resourceMonitorPlatformKey(platform, architecture); | ||
| 121 | const rustTarget = resourceMonitorRustTarget(platform, architecture); | ||
| 122 | if (platformKey === undefined || rustTarget === undefined) { | ||
| 123 | return ResourceMonitorBinary.of({ | ||
| 124 | resolve: Effect.fail( | ||
| 125 | new ResourceMonitorBinaryUnsupported({ | ||
| 126 | platform, | ||
| 127 | architecture, | ||
| 128 | }), | ||
| 129 | ), | ||
| 130 | }); | ||
| 131 | } | ||
| 132 | |||
| 133 | const candidates = [ | ||
| 134 | environment.T3CODE_RESOURCE_MONITOR_PATH, | ||
| 135 | config.resourceMonitorPath, | ||
| 136 | path.resolve(import.meta.dirname, "resource-monitor", platformKey, executableName), | ||
| 137 | path.resolve(import.meta.dirname, "resource-monitor", executableName), | ||
| 138 | path.resolve(import.meta.dirname, "../resource-monitor", executableName), | ||
| 139 | path.resolve( | ||
| 140 | import.meta.dirname, | ||
| 141 | "../../../../native/resource-monitor/target", | ||
| 142 | rustTarget, | ||
| 143 | "release", | ||
| 144 | executableName, | ||
| 145 | ), | ||
| 146 | path.resolve( | ||
| 147 | import.meta.dirname, | ||
| 148 | "../../../native/resource-monitor/target", | ||
| 149 | rustTarget, | ||
| 150 | "release", | ||
| 151 | executableName, | ||
| 152 | ), | ||
| 153 | path.resolve( | ||
| 154 | import.meta.dirname, | ||
| 155 | "../../../../native/resource-monitor/target/release", | ||
| 156 | executableName, | ||
| 157 | ), | ||
| 158 | path.resolve( | ||
| 159 | import.meta.dirname, | ||
| 160 | "../../../../native/resource-monitor/target/debug", | ||
| 161 | executableName, | ||
| 162 | ), | ||
| 163 | ].filter((candidate): candidate is string => Boolean(candidate)); | ||
| 164 | |||
| 165 | const resolve: ResourceMonitorBinaryShape["resolve"] = Effect.gen(function* () { | ||
| 166 | for (const candidate of candidates) { | ||
| 167 | const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); | ||
| 168 | if (!exists) continue; | ||
| 169 | |||
| 170 | if (platform !== "win32") { | ||
| 171 | const stat = yield* fileSystem.stat(candidate).pipe(Effect.option); | ||
| 172 | if (Option.isSome(stat) && (stat.value.mode & 0o111) === 0) { | ||
| 173 | return yield* new ResourceMonitorBinaryNotExecutable({ | ||
| 174 | path: candidate, | ||
| 175 | mode: stat.value.mode, | ||
| 176 | }); | ||
| 177 | } | ||
| 178 | } | ||
| 179 | |||
| 180 | return candidate; | ||
| 181 | } | ||
| 182 | |||
| 183 | return yield* new ResourceMonitorBinaryNotFound({ | ||
| 184 | platform, | ||
| 185 | architecture, | ||
| 186 | candidates, | ||
| 187 | }); | ||
| 188 | }); | ||
| 189 | |||
| 190 | return ResourceMonitorBinary.of({ resolve }); | ||
| 191 | }); | ||
| 192 | |||
| 193 | export const layer = Layer.effect(ResourceMonitorBinary, make()); | ||
| @@ -0,0 +1,302 @@ | |||
| 1 | import type { | ||
| 2 | DesktopHostTelemetrySnapshot, | ||
| 3 | ResourceMonitorProcessSample, | ||
| 4 | ResourceMonitorSnapshotEvent, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import * as DateTime from "effect/DateTime"; | ||
| 8 | import * as Duration from "effect/Duration"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Fiber from "effect/Fiber"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import * as PubSub from "effect/PubSub"; | ||
| 14 | import * as Ref from "effect/Ref"; | ||
| 15 | import * as Stream from "effect/Stream"; | ||
| 16 | import * as TestClock from "effect/testing/TestClock"; | ||
| 17 | |||
| 18 | import * as DesktopTelemetryReceiver from "./DesktopTelemetryReceiver.ts"; | ||
| 19 | import * as NativeTelemetryClient from "./NativeTelemetryClient.ts"; | ||
| 20 | import * as ResourceAttribution from "./ResourceAttribution.ts"; | ||
| 21 | import * as ResourceTelemetry from "./ResourceTelemetry.ts"; | ||
| 22 | |||
| 23 | function processSample( | ||
| 24 | input: Partial<ResourceMonitorProcessSample> & | ||
| 25 | Pick<ResourceMonitorProcessSample, "pid" | "ppid" | "startTimeMs">, | ||
| 26 | ): ResourceMonitorProcessSample { | ||
| 27 | return { | ||
| 28 | runTimeMs: 1_000, | ||
| 29 | name: `process-${input.pid}`, | ||
| 30 | command: `process-${input.pid}`, | ||
| 31 | status: "Running", | ||
| 32 | cpuPercent: 0, | ||
| 33 | cpuTimeMs: 0, | ||
| 34 | residentBytes: 1_024, | ||
| 35 | virtualBytes: 2_048, | ||
| 36 | ioReadBytes: 0, | ||
| 37 | ioWriteBytes: 0, | ||
| 38 | ioSemantics: "storage", | ||
| 39 | ...input, | ||
| 40 | }; | ||
| 41 | } | ||
| 42 | |||
| 43 | function nativeSnapshot(input: { | ||
| 44 | readonly sequence: number; | ||
| 45 | readonly sampledAtUnixMs: number; | ||
| 46 | readonly childCpuTimeMs: number; | ||
| 47 | readonly childWriteBytes: number; | ||
| 48 | }): ResourceMonitorSnapshotEvent { | ||
| 49 | const processes = [ | ||
| 50 | processSample({ | ||
| 51 | pid: process.pid, | ||
| 52 | ppid: 1, | ||
| 53 | startTimeMs: 100, | ||
| 54 | cpuTimeMs: input.sequence * 10, | ||
| 55 | }), | ||
| 56 | processSample({ | ||
| 57 | pid: 4_242, | ||
| 58 | ppid: process.pid, | ||
| 59 | startTimeMs: 200, | ||
| 60 | name: "codex", | ||
| 61 | command: "codex app-server", | ||
| 62 | cpuTimeMs: input.childCpuTimeMs, | ||
| 63 | ioWriteBytes: input.childWriteBytes, | ||
| 64 | }), | ||
| 65 | processSample({ | ||
| 66 | pid: 5_000, | ||
| 67 | ppid: 1, | ||
| 68 | startTimeMs: 300, | ||
| 69 | name: "electron", | ||
| 70 | command: "electron", | ||
| 71 | cpuTimeMs: input.sequence * 20, | ||
| 72 | }), | ||
| 73 | processSample({ | ||
| 74 | pid: 9_000, | ||
| 75 | ppid: process.pid, | ||
| 76 | startTimeMs: 400, | ||
| 77 | name: "t3-resource-monitor", | ||
| 78 | command: "t3-resource-monitor", | ||
| 79 | cpuTimeMs: input.sequence * 5, | ||
| 80 | }), | ||
| 81 | ]; | ||
| 82 | return { | ||
| 83 | version: 1, | ||
| 84 | type: "snapshot", | ||
| 85 | sequence: input.sequence, | ||
| 86 | sampledAtUnixMs: input.sampledAtUnixMs, | ||
| 87 | collectionDurationMicros: 300, | ||
| 88 | scannedProcessCount: 80, | ||
| 89 | retainedProcessCount: processes.length, | ||
| 90 | inaccessibleProcessCount: 1, | ||
| 91 | processes, | ||
| 92 | }; | ||
| 93 | } | ||
| 94 | |||
| 95 | function desktopSnapshot(sampledAtUnixMs: number): DesktopHostTelemetrySnapshot { | ||
| 96 | const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs); | ||
| 97 | return { | ||
| 98 | version: 1, | ||
| 99 | type: "desktopTelemetry", | ||
| 100 | sequence: 1, | ||
| 101 | sampledAtUnixMs, | ||
| 102 | power: { | ||
| 103 | source: "electron-main", | ||
| 104 | idle: "false", | ||
| 105 | idleSeconds: 2, | ||
| 106 | locked: "false", | ||
| 107 | suspended: false, | ||
| 108 | onBattery: "true", | ||
| 109 | lowPowerMode: "unknown", | ||
| 110 | thermalState: "fair", | ||
| 111 | stale: false, | ||
| 112 | updatedAt: sampledAt, | ||
| 113 | }, | ||
| 114 | speedLimitPercent: Option.some(90), | ||
| 115 | electronProcesses: [ | ||
| 116 | { | ||
| 117 | pid: 5_000, | ||
| 118 | creationTimeMs: 300, | ||
| 119 | type: "Browser", | ||
| 120 | name: "electron", | ||
| 121 | cpuPercent: 2, | ||
| 122 | cumulativeCpuSeconds: 0.02, | ||
| 123 | idleWakeupsPerSecond: 3, | ||
| 124 | workingSetBytes: 4_096, | ||
| 125 | peakWorkingSetBytes: 8_192, | ||
| 126 | }, | ||
| 127 | ], | ||
| 128 | }; | ||
| 129 | } | ||
| 130 | |||
| 131 | describe("ResourceTelemetry", () => { | ||
| 132 | it.effect("combines native, Electron, attribution, retry, and history data", () => | ||
| 133 | Effect.gen(function* () { | ||
| 134 | const startedAt = DateTime.toEpochMillis(yield* DateTime.now); | ||
| 135 | const samples = [ | ||
| 136 | nativeSnapshot({ | ||
| 137 | sequence: 1, | ||
| 138 | sampledAtUnixMs: startedAt, | ||
| 139 | childCpuTimeMs: 100, | ||
| 140 | childWriteBytes: 1_000, | ||
| 141 | }), | ||
| 142 | nativeSnapshot({ | ||
| 143 | sequence: 2, | ||
| 144 | sampledAtUnixMs: startedAt + 1_000, | ||
| 145 | childCpuTimeMs: 350, | ||
| 146 | childWriteBytes: 5_000, | ||
| 147 | }), | ||
| 148 | nativeSnapshot({ | ||
| 149 | sequence: 1, | ||
| 150 | sampledAtUnixMs: startedAt + 2_000, | ||
| 151 | childCpuTimeMs: 500, | ||
| 152 | childWriteBytes: 7_000, | ||
| 153 | }), | ||
| 154 | ] as const; | ||
| 155 | const sampleIndex = yield* Ref.make(0); | ||
| 156 | const externalProcesses = yield* Ref.make< | ||
| 157 | ReadonlyArray<{ readonly pid: number; readonly startTimeMs?: number }> | ||
| 158 | >([]); | ||
| 159 | const retryCount = yield* Ref.make(0); | ||
| 160 | const nativeHealth = yield* Ref.make<NativeTelemetryClient.NativeTelemetryClientHealth>({ | ||
| 161 | status: "healthy", | ||
| 162 | hello: Option.some({ | ||
| 163 | version: 1, | ||
| 164 | type: "hello", | ||
| 165 | sidecarVersion: "0.1.0", | ||
| 166 | sidecarPid: 9_000, | ||
| 167 | platform: "test", | ||
| 168 | arch: "test", | ||
| 169 | capabilities: { | ||
| 170 | cumulativeCpuTime: true, | ||
| 171 | currentCpuPercent: true, | ||
| 172 | residentMemory: true, | ||
| 173 | virtualMemory: true, | ||
| 174 | ioBytes: true, | ||
| 175 | processStartTime: true, | ||
| 176 | processTree: true, | ||
| 177 | }, | ||
| 178 | }), | ||
| 179 | lastSampleAt: Option.some(DateTime.makeUnsafe(startedAt)), | ||
| 180 | lastError: Option.none(), | ||
| 181 | restartCount: 2, | ||
| 182 | }); | ||
| 183 | const nativeHealthChanges = | ||
| 184 | yield* PubSub.sliding<NativeTelemetryClient.NativeTelemetryClientHealth>(4); | ||
| 185 | const nativeLayer = NativeTelemetryClient.layerTest({ | ||
| 186 | setExternalProcesses: (processes) => Ref.set(externalProcesses, processes), | ||
| 187 | sampleNow: Ref.modify(sampleIndex, (index) => [ | ||
| 188 | samples[Math.min(index, samples.length - 1)]!, | ||
| 189 | index + 1, | ||
| 190 | ]), | ||
| 191 | retry: Ref.updateAndGet(retryCount, (count) => count + 1).pipe(Effect.as(true)), | ||
| 192 | health: Ref.get(nativeHealth), | ||
| 193 | healthChanges: Stream.fromPubSub(nativeHealthChanges), | ||
| 194 | }); | ||
| 195 | const desktopLayer = DesktopTelemetryReceiver.layerTest({ | ||
| 196 | latest: Effect.succeedSome(desktopSnapshot(startedAt)), | ||
| 197 | health: Effect.succeed({ | ||
| 198 | status: "healthy", | ||
| 199 | lastSampleAt: Option.some(DateTime.makeUnsafe(startedAt)), | ||
| 200 | lastError: Option.none(), | ||
| 201 | }), | ||
| 202 | }); | ||
| 203 | const attributionLayer = ResourceAttribution.layer; | ||
| 204 | const dependencies = Layer.mergeAll(nativeLayer, desktopLayer, attributionLayer); | ||
| 205 | const telemetryLayer = ResourceTelemetry.layer.pipe(Layer.provide(dependencies)); | ||
| 206 | const layer = Layer.mergeAll(dependencies, telemetryLayer); | ||
| 207 | |||
| 208 | yield* Effect.gen(function* () { | ||
| 209 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; | ||
| 210 | const attribution = yield* ResourceAttribution.ResourceAttribution; | ||
| 211 | |||
| 212 | expect(yield* Ref.get(externalProcesses)).toEqual([{ pid: 5_000, startTimeMs: 300 }]); | ||
| 213 | |||
| 214 | yield* attribution.record({ | ||
| 215 | component: "provider-event-log", | ||
| 216 | operation: "append", | ||
| 217 | logicalWriteBytes: 512, | ||
| 218 | count: 2, | ||
| 219 | durationMs: 4, | ||
| 220 | }); | ||
| 221 | const first = yield* telemetry.refresh; | ||
| 222 | expect(first.groups.backend.processCount).toBe(2); | ||
| 223 | expect(first.groups.electron.processCount).toBe(1); | ||
| 224 | expect(first.groups.monitor.processCount).toBe(1); | ||
| 225 | expect(first.power.onBattery).toBe("true"); | ||
| 226 | expect(Option.getOrNull(first.speedLimitPercent)).toBe(90); | ||
| 227 | expect(first.attribution.entries).toEqual([ | ||
| 228 | { | ||
| 229 | component: "provider-event-log", | ||
| 230 | operation: "append", | ||
| 231 | logicalReadBytes: 0, | ||
| 232 | logicalWriteBytes: 512, | ||
| 233 | count: 2, | ||
| 234 | durationMs: 4, | ||
| 235 | }, | ||
| 236 | ]); | ||
| 237 | |||
| 238 | yield* TestClock.adjust(Duration.seconds(1)); | ||
| 239 | const second = yield* telemetry.refresh; | ||
| 240 | const codex = second.processes.find((entry) => entry.identity.pid === 4_242); | ||
| 241 | expect(codex?.cpuPercent).toBe(25); | ||
| 242 | expect(codex?.ioWriteBytesPerSecond).toBe(4_000); | ||
| 243 | expect(second.groups.backend.ioWriteBytes).toBe(4_000); | ||
| 244 | expect(second.health.collectionDurationMicros).toBe(300); | ||
| 245 | expect(second.health.scannedProcessCount).toBe(80); | ||
| 246 | expect(second.health.inaccessibleProcessCount).toBe(1); | ||
| 247 | |||
| 248 | const history = yield* telemetry.readHistory({ | ||
| 249 | windowMs: 60_000, | ||
| 250 | bucketMs: 10_000, | ||
| 251 | }); | ||
| 252 | expect(history.retainedSampleCount).toBeGreaterThan(0); | ||
| 253 | expect( | ||
| 254 | history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.sampleCount, | ||
| 255 | ).toBe(2); | ||
| 256 | expect(history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.cpuTimeMs).toBe( | ||
| 257 | 250, | ||
| 258 | ); | ||
| 259 | expect( | ||
| 260 | history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.ioWriteBytes, | ||
| 261 | ).toBe(4_000); | ||
| 262 | expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe( | ||
| 263 | 4_000, | ||
| 264 | ); | ||
| 265 | |||
| 266 | const retry = yield* telemetry.retry; | ||
| 267 | expect(retry.accepted).toBe(true); | ||
| 268 | expect(yield* Ref.get(retryCount)).toBe(1); | ||
| 269 | |||
| 270 | yield* Ref.update(nativeHealth, (current) => ({ | ||
| 271 | ...current, | ||
| 272 | hello: Option.map(current.hello, (hello) => ({ | ||
| 273 | ...hello, | ||
| 274 | sidecarPid: 9_001, | ||
| 275 | })), | ||
| 276 | restartCount: 3, | ||
| 277 | })); | ||
| 278 | yield* TestClock.adjust(Duration.seconds(1)); | ||
| 279 | const restarted = yield* telemetry.refresh; | ||
| 280 | expect(DateTime.toEpochMillis(restarted.readAt)).toBe(startedAt + 2_000); | ||
| 281 | expect(Option.getOrNull(restarted.health.sidecarPid)).toBe(9_001); | ||
| 282 | |||
| 283 | const healthUpdateFiber = yield* Stream.runHead(telemetry.changes).pipe(Effect.forkChild); | ||
| 284 | yield* Effect.yieldNow; | ||
| 285 | yield* Ref.update(nativeHealth, (current) => ({ | ||
| 286 | ...current, | ||
| 287 | status: "degraded" as const, | ||
| 288 | lastError: Option.some("collector exited"), | ||
| 289 | })); | ||
| 290 | yield* PubSub.publish(nativeHealthChanges, yield* Ref.get(nativeHealth)); | ||
| 291 | const healthUpdate = Option.getOrThrow(yield* Fiber.join(healthUpdateFiber)); | ||
| 292 | expect(healthUpdate.health.native.status).toBe("degraded"); | ||
| 293 | expect(Option.getOrNull(healthUpdate.health.native.lastError)).toBe("collector exited"); | ||
| 294 | const degradedHistory = yield* telemetry.readHistory({ | ||
| 295 | windowMs: 60_000, | ||
| 296 | bucketMs: 10_000, | ||
| 297 | }); | ||
| 298 | expect(degradedHistory.health.native.status).toBe("degraded"); | ||
| 299 | }).pipe(Effect.provide(layer)); | ||
| 300 | }), | ||
| 301 | ); | ||
| 302 | }); | ||
| @@ -0,0 +1,372 @@ | |||
| 1 | import type { | ||
| 2 | DesktopHostTelemetrySnapshot, | ||
| 3 | HostPowerSnapshot, | ||
| 4 | ResourceMonitorSnapshotEvent, | ||
| 5 | ResourceTelemetryHealth, | ||
| 6 | ResourceTelemetryHistory, | ||
| 7 | ResourceTelemetryHistoryInput, | ||
| 8 | ResourceTelemetryProcessIdentity, | ||
| 9 | ResourceTelemetryRetryResult, | ||
| 10 | ResourceTelemetrySnapshot, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Layer from "effect/Layer"; | ||
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import * as PubSub from "effect/PubSub"; | ||
| 18 | import * as Ref from "effect/Ref"; | ||
| 19 | import * as Schema from "effect/Schema"; | ||
| 20 | import * as Semaphore from "effect/Semaphore"; | ||
| 21 | import * as Stream from "effect/Stream"; | ||
| 22 | |||
| 23 | import * as DesktopTelemetryReceiver from "./DesktopTelemetryReceiver.ts"; | ||
| 24 | import { | ||
| 25 | emptyTelemetryCounters, | ||
| 26 | mergeProcesses, | ||
| 27 | type ProcessState, | ||
| 28 | type TelemetryCounters, | ||
| 29 | } from "./Model.ts"; | ||
| 30 | import * as NativeTelemetryClient from "./NativeTelemetryClient.ts"; | ||
| 31 | import * as ResourceAttribution from "./ResourceAttribution.ts"; | ||
| 32 | import * as ResourceTelemetryStore from "./ResourceTelemetryStore.ts"; | ||
| 33 | |||
| 34 | const SAMPLE_INTERVAL_MS = 1_000; | ||
| 35 | |||
| 36 | export class ResourceTelemetryRefreshFailed extends Schema.TaggedErrorClass<ResourceTelemetryRefreshFailed>()( | ||
| 37 | "ResourceTelemetryRefreshFailed", | ||
| 38 | { | ||
| 39 | operation: Schema.String, | ||
| 40 | cause: Schema.Defect(), | ||
| 41 | }, | ||
| 42 | ) { | ||
| 43 | override get message(): string { | ||
| 44 | return `Resource telemetry operation '${this.operation}' failed.`; | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | export interface ResourceTelemetryShape { | ||
| 49 | readonly latest: Effect.Effect<ResourceTelemetrySnapshot>; | ||
| 50 | readonly changes: Stream.Stream<ResourceTelemetrySnapshot>; | ||
| 51 | readonly readHistory: ( | ||
| 52 | input: ResourceTelemetryHistoryInput, | ||
| 53 | ) => Effect.Effect<ResourceTelemetryHistory>; | ||
| 54 | readonly refresh: Effect.Effect<ResourceTelemetrySnapshot, ResourceTelemetryRefreshFailed>; | ||
| 55 | readonly validateProcessIdentity: ( | ||
| 56 | identity: ResourceTelemetryProcessIdentity, | ||
| 57 | ) => Effect.Effect<boolean, ResourceTelemetryRefreshFailed>; | ||
| 58 | readonly retry: Effect.Effect<ResourceTelemetryRetryResult>; | ||
| 59 | } | ||
| 60 | |||
| 61 | export class ResourceTelemetry extends Context.Service<ResourceTelemetry, ResourceTelemetryShape>()( | ||
| 62 | "t3/resourceTelemetry/ResourceTelemetry", | ||
| 63 | ) {} | ||
| 64 | |||
| 65 | interface TelemetryState { | ||
| 66 | readonly nativeSnapshot: Option.Option<ResourceMonitorSnapshotEvent>; | ||
| 67 | readonly desktopSnapshot: Option.Option<DesktopHostTelemetrySnapshot>; | ||
| 68 | readonly previous: ReadonlyMap<string, ProcessState>; | ||
| 69 | readonly counters: TelemetryCounters; | ||
| 70 | readonly latest: ResourceTelemetrySnapshot; | ||
| 71 | readonly lastNativeSequence: number; | ||
| 72 | readonly lastNativeRestartCount: number; | ||
| 73 | } | ||
| 74 | |||
| 75 | function unknownPower(updatedAt: DateTime.Utc): HostPowerSnapshot { | ||
| 76 | return { | ||
| 77 | source: "unknown", | ||
| 78 | idle: "unknown", | ||
| 79 | idleSeconds: null, | ||
| 80 | locked: "unknown", | ||
| 81 | suspended: false, | ||
| 82 | onBattery: "unknown", | ||
| 83 | lowPowerMode: "unknown", | ||
| 84 | thermalState: "unknown", | ||
| 85 | stale: true, | ||
| 86 | updatedAt, | ||
| 87 | }; | ||
| 88 | } | ||
| 89 | |||
| 90 | function buildHealth(input: { | ||
| 91 | readonly native: NativeTelemetryClient.NativeTelemetryClientHealth; | ||
| 92 | readonly desktop: DesktopTelemetryReceiver.DesktopTelemetryReceiverHealth; | ||
| 93 | readonly nativeSnapshot: Option.Option<ResourceMonitorSnapshotEvent>; | ||
| 94 | }): ResourceTelemetryHealth { | ||
| 95 | return { | ||
| 96 | native: { | ||
| 97 | status: input.native.status, | ||
| 98 | lastSampleAt: input.native.lastSampleAt, | ||
| 99 | lastError: input.native.lastError, | ||
| 100 | }, | ||
| 101 | desktop: { | ||
| 102 | status: input.desktop.status, | ||
| 103 | lastSampleAt: input.desktop.lastSampleAt, | ||
| 104 | lastError: input.desktop.lastError, | ||
| 105 | }, | ||
| 106 | sidecarVersion: Option.map(input.native.hello, (hello) => hello.sidecarVersion), | ||
| 107 | sidecarPid: Option.map(input.native.hello, (hello) => hello.sidecarPid), | ||
| 108 | restartCount: input.native.restartCount, | ||
| 109 | collectionDurationMicros: Option.match(input.nativeSnapshot, { | ||
| 110 | onNone: () => 0, | ||
| 111 | onSome: (snapshot) => snapshot.collectionDurationMicros, | ||
| 112 | }), | ||
| 113 | scannedProcessCount: Option.match(input.nativeSnapshot, { | ||
| 114 | onNone: () => 0, | ||
| 115 | onSome: (snapshot) => snapshot.scannedProcessCount, | ||
| 116 | }), | ||
| 117 | retainedProcessCount: Option.match(input.nativeSnapshot, { | ||
| 118 | onNone: () => 0, | ||
| 119 | onSome: (snapshot) => snapshot.retainedProcessCount, | ||
| 120 | }), | ||
| 121 | inaccessibleProcessCount: Option.match(input.nativeSnapshot, { | ||
| 122 | onNone: () => 0, | ||
| 123 | onSome: (snapshot) => snapshot.inaccessibleProcessCount, | ||
| 124 | }), | ||
| 125 | }; | ||
| 126 | } | ||
| 127 | |||
| 128 | export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(function* () { | ||
| 129 | const nativeClient = yield* NativeTelemetryClient.NativeTelemetryClient; | ||
| 130 | const desktopReceiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver; | ||
| 131 | const attribution = yield* ResourceAttribution.ResourceAttribution; | ||
| 132 | const mutex = yield* Semaphore.make(1); | ||
| 133 | const changes = yield* PubSub.sliding<ResourceTelemetrySnapshot>(8); | ||
| 134 | const initialReadAt = yield* DateTime.now; | ||
| 135 | const initialDesktop = yield* desktopReceiver.latest; | ||
| 136 | if (Option.isSome(initialDesktop)) { | ||
| 137 | yield* nativeClient | ||
| 138 | .setExternalProcesses( | ||
| 139 | initialDesktop.value.electronProcesses.map((process) => ({ | ||
| 140 | pid: process.pid, | ||
| 141 | startTimeMs: process.creationTimeMs, | ||
| 142 | })), | ||
| 143 | ) | ||
| 144 | .pipe(Effect.ignore); | ||
| 145 | } | ||
| 146 | const [initialNativeHealth, initialDesktopHealth, initialAttribution] = yield* Effect.all([ | ||
| 147 | nativeClient.health, | ||
| 148 | desktopReceiver.health, | ||
| 149 | attribution.snapshot, | ||
| 150 | ]); | ||
| 151 | const initialMerge = mergeProcesses({ | ||
| 152 | serverPid: process.pid, | ||
| 153 | sidecarPid: Option.map(initialNativeHealth.hello, (hello) => hello.sidecarPid), | ||
| 154 | fallbackSampledAtMs: DateTime.toEpochMillis(initialReadAt), | ||
| 155 | nativeSnapshot: Option.none(), | ||
| 156 | desktopSnapshot: initialDesktop, | ||
| 157 | previous: new Map(), | ||
| 158 | counters: emptyTelemetryCounters(), | ||
| 159 | updatePrevious: false, | ||
| 160 | }); | ||
| 161 | const initialSnapshot: ResourceTelemetrySnapshot = { | ||
| 162 | readAt: initialReadAt, | ||
| 163 | sampleIntervalMs: SAMPLE_INTERVAL_MS, | ||
| 164 | processes: initialMerge.processes, | ||
| 165 | groups: initialMerge.groups, | ||
| 166 | power: Option.match(initialDesktop, { | ||
| 167 | onNone: () => unknownPower(initialReadAt), | ||
| 168 | onSome: (desktop) => desktop.power, | ||
| 169 | }), | ||
| 170 | speedLimitPercent: Option.flatMap(initialDesktop, (desktop) => desktop.speedLimitPercent), | ||
| 171 | attribution: initialAttribution, | ||
| 172 | health: buildHealth({ | ||
| 173 | native: initialNativeHealth, | ||
| 174 | desktop: initialDesktopHealth, | ||
| 175 | nativeSnapshot: Option.none(), | ||
| 176 | }), | ||
| 177 | }; | ||
| 178 | const state = yield* Ref.make<TelemetryState>({ | ||
| 179 | nativeSnapshot: Option.none(), | ||
| 180 | desktopSnapshot: initialDesktop, | ||
| 181 | previous: new Map(), | ||
| 182 | counters: emptyTelemetryCounters(), | ||
| 183 | latest: initialSnapshot, | ||
| 184 | lastNativeSequence: 0, | ||
| 185 | lastNativeRestartCount: initialNativeHealth.restartCount, | ||
| 186 | }); | ||
| 187 | const store = yield* ResourceTelemetryStore.make(initialSnapshot); | ||
| 188 | const refreshHealth = mutex.withPermits(1)( | ||
| 189 | Effect.gen(function* () { | ||
| 190 | const current = yield* Ref.get(state); | ||
| 191 | const [nativeHealth, desktopHealth] = yield* Effect.all([ | ||
| 192 | nativeClient.health, | ||
| 193 | desktopReceiver.health, | ||
| 194 | ]); | ||
| 195 | const snapshot: ResourceTelemetrySnapshot = { | ||
| 196 | ...current.latest, | ||
| 197 | health: buildHealth({ | ||
| 198 | native: nativeHealth, | ||
| 199 | desktop: desktopHealth, | ||
| 200 | nativeSnapshot: current.nativeSnapshot, | ||
| 201 | }), | ||
| 202 | }; | ||
| 203 | yield* Ref.set(state, { | ||
| 204 | ...current, | ||
| 205 | latest: snapshot, | ||
| 206 | }); | ||
| 207 | yield* store.updateLatest(snapshot); | ||
| 208 | yield* PubSub.publish(changes, snapshot); | ||
| 209 | }), | ||
| 210 | ); | ||
| 211 | |||
| 212 | const rebuild = (input: { | ||
| 213 | readonly nativeSnapshot?: ResourceMonitorSnapshotEvent; | ||
| 214 | readonly desktopSnapshot?: DesktopHostTelemetrySnapshot; | ||
| 215 | readonly updatePrevious: boolean; | ||
| 216 | }): Effect.Effect<ResourceTelemetrySnapshot> => | ||
| 217 | mutex.withPermits(1)( | ||
| 218 | Effect.gen(function* () { | ||
| 219 | const current = yield* Ref.get(state); | ||
| 220 | const nativeHealth = yield* nativeClient.health; | ||
| 221 | const nativeGenerationChanged = | ||
| 222 | nativeHealth.restartCount !== current.lastNativeRestartCount; | ||
| 223 | if ( | ||
| 224 | input.nativeSnapshot && | ||
| 225 | !nativeGenerationChanged && | ||
| 226 | input.nativeSnapshot.sequence <= current.lastNativeSequence | ||
| 227 | ) { | ||
| 228 | return current.latest; | ||
| 229 | } | ||
| 230 | const nativeSnapshot = input.nativeSnapshot | ||
| 231 | ? Option.some(input.nativeSnapshot) | ||
| 232 | : current.nativeSnapshot; | ||
| 233 | const desktopSnapshot = input.desktopSnapshot | ||
| 234 | ? Option.some(input.desktopSnapshot) | ||
| 235 | : current.desktopSnapshot; | ||
| 236 | const [desktopHealth, attributionSnapshot] = yield* Effect.all([ | ||
| 237 | desktopReceiver.health, | ||
| 238 | attribution.snapshot, | ||
| 239 | ]); | ||
| 240 | const merged = mergeProcesses({ | ||
| 241 | serverPid: process.pid, | ||
| 242 | sidecarPid: Option.map(nativeHealth.hello, (hello) => hello.sidecarPid), | ||
| 243 | fallbackSampledAtMs: DateTime.toEpochMillis(current.latest.readAt), | ||
| 244 | nativeSnapshot, | ||
| 245 | desktopSnapshot, | ||
| 246 | previous: current.previous, | ||
| 247 | counters: current.counters, | ||
| 248 | updatePrevious: input.updatePrevious, | ||
| 249 | }); | ||
| 250 | const readAt = DateTime.makeUnsafe(merged.sampledAtMs); | ||
| 251 | const snapshot: ResourceTelemetrySnapshot = { | ||
| 252 | readAt, | ||
| 253 | sampleIntervalMs: SAMPLE_INTERVAL_MS, | ||
| 254 | processes: merged.processes, | ||
| 255 | groups: merged.groups, | ||
| 256 | power: Option.match(desktopSnapshot, { | ||
| 257 | onNone: () => unknownPower(readAt), | ||
| 258 | onSome: (desktop) => desktop.power, | ||
| 259 | }), | ||
| 260 | speedLimitPercent: Option.match(desktopSnapshot, { | ||
| 261 | onNone: () => Option.none(), | ||
| 262 | onSome: (desktop) => desktop.speedLimitPercent, | ||
| 263 | }), | ||
| 264 | attribution: attributionSnapshot, | ||
| 265 | health: buildHealth({ | ||
| 266 | native: nativeHealth, | ||
| 267 | desktop: desktopHealth, | ||
| 268 | nativeSnapshot, | ||
| 269 | }), | ||
| 270 | }; | ||
| 271 | yield* Ref.set(state, { | ||
| 272 | nativeSnapshot, | ||
| 273 | desktopSnapshot, | ||
| 274 | previous: merged.previous, | ||
| 275 | counters: merged.counters, | ||
| 276 | latest: snapshot, | ||
| 277 | lastNativeSequence: input.nativeSnapshot?.sequence ?? current.lastNativeSequence, | ||
| 278 | lastNativeRestartCount: input.nativeSnapshot | ||
| 279 | ? nativeHealth.restartCount | ||
| 280 | : current.lastNativeRestartCount, | ||
| 281 | }); | ||
| 282 | if (input.updatePrevious) { | ||
| 283 | yield* store.record(snapshot, merged.deltas); | ||
| 284 | } | ||
| 285 | yield* PubSub.publish(changes, snapshot); | ||
| 286 | return snapshot; | ||
| 287 | }), | ||
| 288 | ); | ||
| 289 | |||
| 290 | const ingestNative = (snapshot: ResourceMonitorSnapshotEvent) => | ||
| 291 | rebuild({ nativeSnapshot: snapshot, updatePrevious: true }); | ||
| 292 | const ingestDesktop = (snapshot: DesktopHostTelemetrySnapshot) => | ||
| 293 | Effect.gen(function* () { | ||
| 294 | yield* nativeClient | ||
| 295 | .setExternalProcesses( | ||
| 296 | snapshot.electronProcesses.map((process) => ({ | ||
| 297 | pid: process.pid, | ||
| 298 | startTimeMs: process.creationTimeMs, | ||
| 299 | })), | ||
| 300 | ) | ||
| 301 | .pipe(Effect.ignore); | ||
| 302 | return yield* rebuild({ desktopSnapshot: snapshot, updatePrevious: false }); | ||
| 303 | }); | ||
| 304 | |||
| 305 | yield* nativeClient.snapshots.pipe( | ||
| 306 | Stream.runForEach((snapshot) => ingestNative(snapshot)), | ||
| 307 | Effect.catch((error) => | ||
| 308 | Effect.logWarning("Native resource telemetry stream stopped", { | ||
| 309 | cause: error.message, | ||
| 310 | }), | ||
| 311 | ), | ||
| 312 | Effect.forkScoped, | ||
| 313 | ); | ||
| 314 | yield* desktopReceiver.changes.pipe( | ||
| 315 | Stream.runForEach((snapshot) => ingestDesktop(snapshot)), | ||
| 316 | Effect.forkScoped, | ||
| 317 | ); | ||
| 318 | yield* nativeClient.healthChanges.pipe( | ||
| 319 | Stream.runForEach(() => refreshHealth), | ||
| 320 | Effect.forkScoped, | ||
| 321 | ); | ||
| 322 | yield* desktopReceiver.healthChanges.pipe( | ||
| 323 | Stream.runForEach(() => refreshHealth), | ||
| 324 | Effect.forkScoped, | ||
| 325 | ); | ||
| 326 | |||
| 327 | const refresh: ResourceTelemetryShape["refresh"] = nativeClient.sampleNow.pipe( | ||
| 328 | Effect.flatMap(ingestNative), | ||
| 329 | Effect.mapError( | ||
| 330 | (cause) => | ||
| 331 | new ResourceTelemetryRefreshFailed({ | ||
| 332 | operation: "refresh", | ||
| 333 | cause, | ||
| 334 | }), | ||
| 335 | ), | ||
| 336 | ); | ||
| 337 | |||
| 338 | const validateProcessIdentity: ResourceTelemetryShape["validateProcessIdentity"] = (identity) => | ||
| 339 | nativeClient.sampleNow.pipe( | ||
| 340 | Effect.map((snapshot) => | ||
| 341 | snapshot.processes.some( | ||
| 342 | (process) => process.pid === identity.pid && process.startTimeMs === identity.startTimeMs, | ||
| 343 | ), | ||
| 344 | ), | ||
| 345 | Effect.mapError( | ||
| 346 | (cause) => | ||
| 347 | new ResourceTelemetryRefreshFailed({ | ||
| 348 | operation: "validateProcessIdentity", | ||
| 349 | cause, | ||
| 350 | }), | ||
| 351 | ), | ||
| 352 | ); | ||
| 353 | |||
| 354 | return ResourceTelemetry.of({ | ||
| 355 | latest: Ref.get(state).pipe(Effect.map((current) => current.latest)), | ||
| 356 | changes: Stream.fromPubSub(changes), | ||
| 357 | readHistory: store.readHistory, | ||
| 358 | refresh, | ||
| 359 | validateProcessIdentity, | ||
| 360 | retry: nativeClient.retry.pipe( | ||
| 361 | Effect.zip(Ref.get(state)), | ||
| 362 | Effect.map( | ||
| 363 | ([accepted, current]): ResourceTelemetryRetryResult => ({ | ||
| 364 | accepted, | ||
| 365 | snapshot: current.latest, | ||
| 366 | }), | ||
| 367 | ), | ||
| 368 | ), | ||
| 369 | }); | ||
| 370 | }); | ||
| 371 | |||
| 372 | export const layer = Layer.effect(ResourceTelemetry, make()); | ||
| @@ -0,0 +1,253 @@ | |||
| 1 | import type { | ||
| 2 | ResourceTelemetryHistory, | ||
| 3 | ResourceTelemetryHistoryBucket, | ||
| 4 | ResourceTelemetryHistoryInput, | ||
| 5 | ResourceTelemetryProcess, | ||
| 6 | ResourceTelemetryProcessSummary, | ||
| 7 | ResourceTelemetrySnapshot, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as Context from "effect/Context"; | ||
| 10 | import * as DateTime from "effect/DateTime"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as Ref from "effect/Ref"; | ||
| 14 | |||
| 15 | import type { ProcessDelta } from "./Model.ts"; | ||
| 16 | import { processIdentityKey } from "./Model.ts"; | ||
| 17 | |||
| 18 | const RETENTION_MS = 60 * 60_000; | ||
| 19 | const MAX_AGGREGATE_SAMPLES = 3_600; | ||
| 20 | const MAX_PROCESS_SAMPLES = 20_000; | ||
| 21 | |||
| 22 | interface AggregateSample { | ||
| 23 | readonly sampledAtMs: number; | ||
| 24 | readonly cpuPercent: number; | ||
| 25 | readonly rssBytes: number; | ||
| 26 | readonly processCount: number; | ||
| 27 | readonly ioReadBytes: number; | ||
| 28 | readonly ioWriteBytes: number; | ||
| 29 | } | ||
| 30 | |||
| 31 | interface ProcessSample { | ||
| 32 | readonly sampledAtMs: number; | ||
| 33 | readonly process: ResourceTelemetryProcess; | ||
| 34 | readonly cpuTimeMs: number; | ||
| 35 | readonly ioReadBytes: number; | ||
| 36 | readonly ioWriteBytes: number; | ||
| 37 | } | ||
| 38 | |||
| 39 | interface StoreState { | ||
| 40 | readonly aggregateSamples: ReadonlyArray<AggregateSample>; | ||
| 41 | readonly processSamples: ReadonlyArray<ProcessSample>; | ||
| 42 | readonly latest: ResourceTelemetrySnapshot; | ||
| 43 | } | ||
| 44 | |||
| 45 | export interface ResourceTelemetryStoreShape { | ||
| 46 | readonly updateLatest: (snapshot: ResourceTelemetrySnapshot) => Effect.Effect<void>; | ||
| 47 | readonly record: ( | ||
| 48 | snapshot: ResourceTelemetrySnapshot, | ||
| 49 | deltas: ReadonlyArray<ProcessDelta>, | ||
| 50 | ) => Effect.Effect<void>; | ||
| 51 | readonly readHistory: ( | ||
| 52 | input: ResourceTelemetryHistoryInput, | ||
| 53 | ) => Effect.Effect<ResourceTelemetryHistory>; | ||
| 54 | } | ||
| 55 | |||
| 56 | export class ResourceTelemetryStore extends Context.Service< | ||
| 57 | ResourceTelemetryStore, | ||
| 58 | ResourceTelemetryStoreShape | ||
| 59 | >()("t3/resourceTelemetry/ResourceTelemetryStore") {} | ||
| 60 | |||
| 61 | function trimByTime<A extends { readonly sampledAtMs: number }>( | ||
| 62 | values: ReadonlyArray<A>, | ||
| 63 | nowMs: number, | ||
| 64 | max: number, | ||
| 65 | ): ReadonlyArray<A> { | ||
| 66 | const retained = values.filter((value) => value.sampledAtMs >= nowMs - RETENTION_MS); | ||
| 67 | return retained.length <= max ? retained : retained.slice(retained.length - max); | ||
| 68 | } | ||
| 69 | |||
| 70 | function summarizeProcesses( | ||
| 71 | samples: ReadonlyArray<ProcessSample>, | ||
| 72 | ): ReadonlyArray<ResourceTelemetryProcessSummary> { | ||
| 73 | const groups = new Map<string, ProcessSample[]>(); | ||
| 74 | for (const sample of samples) { | ||
| 75 | const identityKey = processIdentityKey( | ||
| 76 | sample.process.identity.pid, | ||
| 77 | sample.process.identity.startTimeMs, | ||
| 78 | ); | ||
| 79 | const current = groups.get(identityKey) ?? []; | ||
| 80 | current.push(sample); | ||
| 81 | groups.set(identityKey, current); | ||
| 82 | } | ||
| 83 | |||
| 84 | return [...groups.values()] | ||
| 85 | .map((processSamples): ResourceTelemetryProcessSummary => { | ||
| 86 | const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs); | ||
| 87 | const first = sorted[0]!; | ||
| 88 | const latest = sorted[sorted.length - 1]!; | ||
| 89 | const cpuTotal = sorted.reduce((total, sample) => total + sample.process.cpuPercent, 0); | ||
| 90 | return { | ||
| 91 | identity: latest.process.identity, | ||
| 92 | ppid: latest.process.ppid, | ||
| 93 | depth: latest.process.depth, | ||
| 94 | name: latest.process.name, | ||
| 95 | command: latest.process.command, | ||
| 96 | category: latest.process.category, | ||
| 97 | firstSeenAt: first.process.firstSeenAt, | ||
| 98 | lastSeenAt: latest.process.lastSeenAt, | ||
| 99 | currentCpuPercent: latest.process.cpuPercent, | ||
| 100 | avgCpuPercent: cpuTotal / sorted.length, | ||
| 101 | maxCpuPercent: Math.max(...sorted.map((sample) => sample.process.cpuPercent)), | ||
| 102 | cpuTimeMs: sorted.reduce((total, sample) => total + sample.cpuTimeMs, 0), | ||
| 103 | currentRssBytes: latest.process.residentBytes, | ||
| 104 | peakRssBytes: Math.max(...sorted.map((sample) => sample.process.peakResidentBytes)), | ||
| 105 | ioReadBytes: sorted.reduce((total, sample) => total + sample.ioReadBytes, 0), | ||
| 106 | ioWriteBytes: sorted.reduce((total, sample) => total + sample.ioWriteBytes, 0), | ||
| 107 | ioSemantics: latest.process.ioSemantics, | ||
| 108 | sampleCount: sorted.length, | ||
| 109 | }; | ||
| 110 | }) | ||
| 111 | .toSorted( | ||
| 112 | (left, right) => right.cpuTimeMs - left.cpuTimeMs || right.peakRssBytes - left.peakRssBytes, | ||
| 113 | ); | ||
| 114 | } | ||
| 115 | |||
| 116 | function buildBuckets(input: { | ||
| 117 | readonly samples: ReadonlyArray<AggregateSample>; | ||
| 118 | readonly nowMs: number; | ||
| 119 | readonly windowMs: number; | ||
| 120 | readonly bucketMs: number; | ||
| 121 | }): ReadonlyArray<ResourceTelemetryHistoryBucket> { | ||
| 122 | const windowStartMs = input.nowMs - input.windowMs; | ||
| 123 | const buckets: ResourceTelemetryHistoryBucket[] = []; | ||
| 124 | for (let startedAtMs = windowStartMs; startedAtMs < input.nowMs; startedAtMs += input.bucketMs) { | ||
| 125 | const endedAtMs = Math.min(input.nowMs, startedAtMs + input.bucketMs); | ||
| 126 | const samples = input.samples.filter( | ||
| 127 | (sample) => | ||
| 128 | sample.sampledAtMs >= startedAtMs && | ||
| 129 | (endedAtMs === input.nowMs | ||
| 130 | ? sample.sampledAtMs <= endedAtMs | ||
| 131 | : sample.sampledAtMs < endedAtMs), | ||
| 132 | ); | ||
| 133 | const cpuTotal = samples.reduce((total, sample) => total + sample.cpuPercent, 0); | ||
| 134 | buckets.push({ | ||
| 135 | startedAt: DateTime.makeUnsafe(startedAtMs), | ||
| 136 | endedAt: DateTime.makeUnsafe(endedAtMs), | ||
| 137 | avgCpuPercent: samples.length === 0 ? 0 : cpuTotal / samples.length, | ||
| 138 | maxCpuPercent: | ||
| 139 | samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.cpuPercent)), | ||
| 140 | maxRssBytes: samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.rssBytes)), | ||
| 141 | ioReadBytes: samples.reduce((total, sample) => total + sample.ioReadBytes, 0), | ||
| 142 | ioWriteBytes: samples.reduce((total, sample) => total + sample.ioWriteBytes, 0), | ||
| 143 | maxProcessCount: | ||
| 144 | samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.processCount)), | ||
| 145 | }); | ||
| 146 | } | ||
| 147 | return buckets; | ||
| 148 | } | ||
| 149 | |||
| 150 | export const make = Effect.fn("resourceTelemetry.resourceTelemetryStore.make")(function* ( | ||
| 151 | initial: ResourceTelemetrySnapshot, | ||
| 152 | ) { | ||
| 153 | const state = yield* Ref.make<StoreState>({ | ||
| 154 | aggregateSamples: [], | ||
| 155 | processSamples: [], | ||
| 156 | latest: initial, | ||
| 157 | }); | ||
| 158 | |||
| 159 | const record: ResourceTelemetryStoreShape["record"] = (snapshot, deltas) => | ||
| 160 | Ref.update(state, (current) => { | ||
| 161 | const sampledAtMs = DateTime.toEpochMillis(snapshot.readAt); | ||
| 162 | const deltasByIdentity = new Map( | ||
| 163 | deltas.map((processDelta) => [processDelta.identityKey, processDelta]), | ||
| 164 | ); | ||
| 165 | const aggregateDelta = deltas.reduce( | ||
| 166 | (total, process) => ({ | ||
| 167 | ioReadBytes: total.ioReadBytes + process.ioReadBytes, | ||
| 168 | ioWriteBytes: total.ioWriteBytes + process.ioWriteBytes, | ||
| 169 | }), | ||
| 170 | { ioReadBytes: 0, ioWriteBytes: 0 }, | ||
| 171 | ); | ||
| 172 | return { | ||
| 173 | latest: snapshot, | ||
| 174 | aggregateSamples: trimByTime( | ||
| 175 | [ | ||
| 176 | ...current.aggregateSamples, | ||
| 177 | { | ||
| 178 | sampledAtMs, | ||
| 179 | cpuPercent: snapshot.groups.allT3.currentCpuPercent, | ||
| 180 | rssBytes: snapshot.groups.allT3.currentRssBytes, | ||
| 181 | processCount: snapshot.groups.allT3.processCount, | ||
| 182 | ioReadBytes: aggregateDelta.ioReadBytes, | ||
| 183 | ioWriteBytes: aggregateDelta.ioWriteBytes, | ||
| 184 | }, | ||
| 185 | ], | ||
| 186 | sampledAtMs, | ||
| 187 | MAX_AGGREGATE_SAMPLES, | ||
| 188 | ), | ||
| 189 | processSamples: trimByTime( | ||
| 190 | [ | ||
| 191 | ...current.processSamples, | ||
| 192 | ...snapshot.processes.map((process) => { | ||
| 193 | const processDelta = deltasByIdentity.get( | ||
| 194 | processIdentityKey(process.identity.pid, process.identity.startTimeMs), | ||
| 195 | ); | ||
| 196 | return { | ||
| 197 | sampledAtMs, | ||
| 198 | process, | ||
| 199 | cpuTimeMs: processDelta?.cpuTimeMs ?? 0, | ||
| 200 | ioReadBytes: processDelta?.ioReadBytes ?? 0, | ||
| 201 | ioWriteBytes: processDelta?.ioWriteBytes ?? 0, | ||
| 202 | }; | ||
| 203 | }), | ||
| 204 | ], | ||
| 205 | sampledAtMs, | ||
| 206 | MAX_PROCESS_SAMPLES, | ||
| 207 | ), | ||
| 208 | }; | ||
| 209 | }); | ||
| 210 | |||
| 211 | const updateLatest: ResourceTelemetryStoreShape["updateLatest"] = (snapshot) => | ||
| 212 | Ref.update(state, (current) => ({ | ||
| 213 | ...current, | ||
| 214 | latest: snapshot, | ||
| 215 | })); | ||
| 216 | |||
| 217 | const readHistory: ResourceTelemetryStoreShape["readHistory"] = (input) => | ||
| 218 | Effect.gen(function* () { | ||
| 219 | const readAt = yield* DateTime.now; | ||
| 220 | const readAtMs = DateTime.toEpochMillis(readAt); | ||
| 221 | const windowMs = Math.max(1_000, Math.min(RETENTION_MS, input.windowMs)); | ||
| 222 | const bucketMs = Math.max(1_000, Math.min(windowMs, input.bucketMs)); | ||
| 223 | const current = yield* Ref.get(state); | ||
| 224 | const minSampledAtMs = readAtMs - windowMs; | ||
| 225 | const aggregateSamples = current.aggregateSamples.filter( | ||
| 226 | (sample) => sample.sampledAtMs >= minSampledAtMs, | ||
| 227 | ); | ||
| 228 | const processSamples = current.processSamples.filter( | ||
| 229 | (sample) => sample.sampledAtMs >= minSampledAtMs, | ||
| 230 | ); | ||
| 231 | |||
| 232 | return { | ||
| 233 | readAt, | ||
| 234 | windowMs, | ||
| 235 | bucketMs, | ||
| 236 | sampleIntervalMs: current.latest.sampleIntervalMs, | ||
| 237 | retainedSampleCount: current.aggregateSamples.length + current.processSamples.length, | ||
| 238 | buckets: buildBuckets({ | ||
| 239 | samples: aggregateSamples, | ||
| 240 | nowMs: readAtMs, | ||
| 241 | windowMs, | ||
| 242 | bucketMs, | ||
| 243 | }), | ||
| 244 | topProcesses: summarizeProcesses(processSamples), | ||
| 245 | health: current.latest.health, | ||
| 246 | }; | ||
| 247 | }); | ||
| 248 | |||
| 249 | return ResourceTelemetryStore.of({ updateLatest, record, readHistory }); | ||
| 250 | }); | ||
| 251 | |||
| 252 | export const layer = (initial: ResourceTelemetrySnapshot) => | ||
| 253 | Layer.effect(ResourceTelemetryStore, make(initial)); | ||
| @@ -141,6 +141,10 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; · 140 unmodified lines | |||
| 141 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 141 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 142 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 142 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 143 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 143 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 144 | import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; | ||
| 145 | import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts"; | ||
| 146 | import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; | ||
| 147 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; | ||
| 144 | import * as Data from "effect/Data"; | 148 | import * as Data from "effect/Data"; |
| 145 | 149 | ||
| 146 | const defaultProjectId = ProjectId.make("project-default"); | 150 | const defaultProjectId = ProjectId.make("project-default"); |
| @@ -368,6 +372,8 @@ const buildAppUnderTest = (options?: { · 221 unmodified lines | |||
| 368 | cloudManagedEndpointRuntime?: Partial<CloudManagedEndpointRuntimeShape>; | 372 | cloudManagedEndpointRuntime?: Partial<CloudManagedEndpointRuntimeShape>; |
| 369 | relayClient?: Partial<RelayClient.RelayClientShape>; | 373 | relayClient?: Partial<RelayClient.RelayClientShape>; |
| 370 | cloudCliTokenManager?: Partial<CloudCliTokenManager.CloudCliTokenManagerShape>; | 374 | cloudCliTokenManager?: Partial<CloudCliTokenManager.CloudCliTokenManagerShape>; |
| 375 | nativeTelemetryClient?: Partial<NativeTelemetryClient.NativeTelemetryClientShape>; | ||
| 376 | desktopTelemetryReceiver?: Partial<DesktopTelemetryReceiver.DesktopTelemetryReceiverShape>; | ||
| 371 | }; | 377 | }; |
| 372 | }) => | 378 | }) => |
| 373 | Effect.gen(function* () { | 379 | Effect.gen(function* () { |
| @@ -540,6 +546,15 @@ const buildAppUnderTest = (options?: { · 166 unmodified lines | |||
| 540 | ...options.layers.vcsStatusBroadcaster, | 546 | ...options.layers.vcsStatusBroadcaster, |
| 541 | }) | 547 | }) |
| 542 | : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); | 548 | : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); |
| 549 | const resourceTelemetryLayer = ResourceTelemetry.layer.pipe( | ||
| 550 | Layer.provide( | ||
| 551 | Layer.mergeAll( | ||
| 552 | NativeTelemetryClient.layerTest(options?.layers?.nativeTelemetryClient), | ||
| 553 | DesktopTelemetryReceiver.layerTest(options?.layers?.desktopTelemetryReceiver), | ||
| 554 | ResourceAttribution.layer, | ||
| 555 | ), | ||
| 556 | ), | ||
| 557 | ); | ||
| 543 | 558 | ||
| 544 | const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { | 559 | const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { |
| 545 | disableListenLog: true, | 560 | disableListenLog: true, |
| @@ -751,6 +766,7 @@ const buildAppUnderTest = (options?: { · 205 unmodified lines | |||
| 751 | ); | 766 | ); |
| 752 | 767 | ||
| 753 | const appLayer = servedRoutesLayer.pipe( | 768 | const appLayer = servedRoutesLayer.pipe( |
| 769 | Layer.provide(resourceTelemetryLayer), | ||
| 754 | Layer.provide( | 770 | Layer.provide( |
| 755 | Layer.mock(BrowserTraceCollector)({ | 771 | Layer.mock(BrowserTraceCollector)({ |
| 756 | record: () => Effect.void, | 772 | record: () => Effect.void, |
| @@ -83,6 +83,11 @@ import * as CloudCliState from "./cloud/CliState.ts"; · 82 unmodified lines | |||
| 83 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 83 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 84 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 84 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 85 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 85 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 86 | import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; | ||
| 87 | import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts"; | ||
| 88 | import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; | ||
| 89 | import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; | ||
| 90 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; | ||
| 86 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; | 91 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; |
| 87 | import { | 92 | import { |
| 88 | clearPersistedServerRuntimeState, | 93 | clearPersistedServerRuntimeState, |
| @@ -99,6 +104,10 @@ import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale" · 10 unmodified lines | |||
| 99 | // already closes the websocket gracefully. Do not add an artificial drain before | 104 | // already closes the websocket gracefully. Do not add an artificial drain before |
| 100 | // those finalizers get a chance to run. | 105 | // those finalizers get a chance to run. |
| 101 | const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; | 106 | const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; |
| 107 | const ResourceAttributionLayerLive = ResourceAttribution.layer; | ||
| 108 | const ApplicationObservabilityLive = ObservabilityLive.pipe( | ||
| 109 | Layer.provideMerge(ResourceAttributionLayerLive), | ||
| 110 | ); | ||
| 102 | 111 | ||
| 103 | const PtyAdapterLive = Layer.unwrap( | 112 | const PtyAdapterLive = Layer.unwrap( |
| 104 | Effect.gen(function* () { | 113 | Effect.gen(function* () { |
| @@ -112,11 +121,30 @@ const PtyAdapterLive = Layer.unwrap( · 7 unmodified lines | |||
| 112 | }), | 121 | }), |
| 113 | ); | 122 | ); |
| 114 | 123 | ||
| 124 | const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe( | ||
| 125 | Layer.provide(ResourceMonitorBinary.layer), | ||
| 126 | ); | ||
| 127 | |||
| 128 | const ResourceTelemetryLayerLive = ResourceTelemetry.layer.pipe( | ||
| 129 | Layer.provideMerge(NativeTelemetryLayerLive), | ||
| 130 | Layer.provideMerge(DesktopTelemetryReceiver.layer), | ||
| 131 | ); | ||
| 132 | |||
| 133 | const HostPowerMonitorLayerLive = HostPowerMonitor.layer.pipe( | ||
| 134 | Layer.provide(ResourceTelemetryLayerLive), | ||
| 135 | ); | ||
| 136 | |||
| 115 | const BackgroundLayerLive = BackgroundPolicy.layer.pipe( | 137 | const BackgroundLayerLive = BackgroundPolicy.layer.pipe( |
| 116 | Layer.provide(HostPowerMonitor.layer), | 138 | Layer.provide(HostPowerMonitorLayerLive), |
| 117 | Layer.provideMerge(ServerSettingsLive), | 139 | Layer.provideMerge(ServerSettingsLive), |
| 118 | ); | 140 | ); |
| 119 | 141 | ||
| 142 | const ResourceDiagnosticsLayerLive = Layer.mergeAll( | ||
| 143 | ResourceTelemetryLayerLive, | ||
| 144 | ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), | ||
| 145 | ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), | ||
| 146 | ); | ||
| 147 | |||
| 120 | const RelayClientLive = Layer.unwrap( | 148 | const RelayClientLive = Layer.unwrap( |
| 121 | Effect.gen(function* () { | 149 | Effect.gen(function* () { |
| 122 | const config = yield* ServerConfig; | 150 | const config = yield* ServerConfig; |
| @@ -337,8 +365,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 214 unmodified lines | |||
| 337 | const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( | 365 | const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( |
| 338 | // Misc. | 366 | // Misc. |
| 339 | Layer.provideMerge(BackgroundLayerLive), | 367 | Layer.provideMerge(BackgroundLayerLive), |
| 340 | Layer.provideMerge(ProcessDiagnostics.layer), | 368 | Layer.provideMerge(ResourceDiagnosticsLayerLive), |
| 341 | Layer.provideMerge(ProcessResourceMonitor.layer), | ||
| 342 | Layer.provideMerge(TraceDiagnostics.layer), | 369 | Layer.provideMerge(TraceDiagnostics.layer), |
| 343 | Layer.provideMerge(AnalyticsServiceLayerLive), | 370 | Layer.provideMerge(AnalyticsServiceLayerLive), |
| 344 | Layer.provideMerge(ExternalLauncher.layer), | 371 | Layer.provideMerge(ExternalLauncher.layer), |
| @@ -488,7 +515,7 @@ export const makeServerLayer = Layer.unwrap( · 143 unmodified lines | |||
| 488 | Layer.provideMerge(RuntimeServicesLive), | 515 | Layer.provideMerge(RuntimeServicesLive), |
| 489 | Layer.provideMerge(serverRelayBrokerTracingLayer), | 516 | Layer.provideMerge(serverRelayBrokerTracingLayer), |
| 490 | Layer.provideMerge(HttpServerLive), | 517 | Layer.provideMerge(HttpServerLive), |
| 491 | Layer.provide(ObservabilityLive), | 518 | Layer.provide(ApplicationObservabilityLive), |
| 492 | Layer.provideMerge(FetchHttpClient.layer), | 519 | Layer.provideMerge(FetchHttpClient.layer), |
| 493 | Layer.provideMerge(VcsProcess.layer), | 520 | Layer.provideMerge(VcsProcess.layer), |
| 494 | Layer.provideMerge(PlatformServicesLive), | 521 | Layer.provideMerge(PlatformServicesLive), |
| @@ -95,6 +95,7 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; · 94 unmodified lines | |||
| 95 | import type { AuthenticatedSession } from "./auth/EnvironmentAuth.ts"; | 95 | import type { AuthenticatedSession } from "./auth/EnvironmentAuth.ts"; |
| 96 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 96 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 97 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 97 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 98 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; | ||
| 98 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 99 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 99 | import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; | 100 | import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; |
| 100 | import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; | 101 | import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; |
| @@ -159,6 +160,9 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ · 58 unmodified lines | |||
| 159 | [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope], | 160 | [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope], |
| 160 | [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], | 161 | [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], |
| 161 | [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], | 162 | [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], |
| 163 | [WS_METHODS.serverGetResourceTelemetry, AuthOrchestrationReadScope], | ||
| 164 | [WS_METHODS.serverGetResourceTelemetryHistory, AuthOrchestrationReadScope], | ||
| 165 | [WS_METHODS.serverRetryResourceTelemetry, AuthOrchestrationOperateScope], | ||
| 162 | [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], | 166 | [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], |
| 163 | [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], | 167 | [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], |
| 164 | [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], | 168 | [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], |
| @@ -173,6 +177,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ · 8 unmodified lines | |||
| 173 | [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], | 177 | [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], |
| 174 | [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], | 178 | [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], |
| 175 | [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], | 179 | [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], |
| 180 | [WS_METHODS.subscribeResourceTelemetry, AuthOrchestrationReadScope], | ||
| 176 | [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], | 181 | [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], |
| 177 | [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], | 182 | [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], |
| 178 | [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope], | 183 | [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope], |
| @@ -298,6 +303,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => · 119 unmodified lines | |||
| 298 | const sessions = yield* SessionStore.SessionStore; | 303 | const sessions = yield* SessionStore.SessionStore; |
| 299 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; | 304 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; |
| 300 | const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; | 305 | const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; |
| 306 | const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; | ||
| 301 | const relayClient = yield* RelayClient.RelayClient; | 307 | const relayClient = yield* RelayClient.RelayClient; |
| 302 | const authorizationError = (requiredScope: AuthEnvironmentScope) => | 308 | const authorizationError = (requiredScope: AuthEnvironmentScope) => |
| 303 | new EnvironmentAuthorizationError({ | 309 | new EnvironmentAuthorizationError({ |
| @@ -1105,6 +1111,22 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => · 801 unmodified lines | |||
| 1105 | "rpc.aggregate": "server", | 1111 | "rpc.aggregate": "server", |
| 1106 | }, | 1112 | }, |
| 1107 | ), | 1113 | ), |
| 1114 | [WS_METHODS.serverGetResourceTelemetry]: (_input) => | ||
| 1115 | observeRpcEffect(WS_METHODS.serverGetResourceTelemetry, resourceTelemetry.latest, { | ||
| 1116 | "rpc.aggregate": "server", | ||
| 1117 | }), | ||
| 1118 | [WS_METHODS.serverGetResourceTelemetryHistory]: (input) => | ||
| 1119 | observeRpcEffect( | ||
| 1120 | WS_METHODS.serverGetResourceTelemetryHistory, | ||
| 1121 | resourceTelemetry.readHistory(input), | ||
| 1122 | { | ||
| 1123 | "rpc.aggregate": "server", | ||
| 1124 | }, | ||
| 1125 | ), | ||
| 1126 | [WS_METHODS.serverRetryResourceTelemetry]: (_input) => | ||
| 1127 | observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { | ||
| 1128 | "rpc.aggregate": "server", | ||
| 1129 | }), | ||
| 1108 | [WS_METHODS.serverSignalProcess]: (input) => | 1130 | [WS_METHODS.serverSignalProcess]: (input) => |
| 1109 | observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { | 1131 | observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { |
| 1110 | "rpc.aggregate": "server", | 1132 | "rpc.aggregate": "server", |
| @@ -1656,6 +1678,15 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => · 545 unmodified lines | |||
| 1656 | ), | 1678 | ), |
| 1657 | { "rpc.aggregate": "server" }, | 1679 | { "rpc.aggregate": "server" }, |
| 1658 | ), | 1680 | ), |
| 1681 | [WS_METHODS.subscribeResourceTelemetry]: (_input) => | ||
| 1682 | observeRpcStream( | ||
| 1683 | WS_METHODS.subscribeResourceTelemetry, | ||
| 1684 | Stream.concat( | ||
| 1685 | Stream.unwrap(Effect.map(resourceTelemetry.latest, Stream.make)), | ||
| 1686 | resourceTelemetry.changes, | ||
| 1687 | ), | ||
| 1688 | { "rpc.aggregate": "server" }, | ||
| 1689 | ), | ||
| 1659 | }); | 1690 | }); |
| 1660 | }), | 1691 | }), |
| 1661 | ); | 1692 | ); |
| @@ -1,18 +1,5 @@ | |||
| 1 | import { | 1 | import { AlertTriangleIcon, CopyIcon, FolderOpenIcon, InfoIcon, RefreshCwIcon } from "lucide-react"; |
| 2 | AlertTriangleIcon, | 2 | import { useCallback, useState, type ReactNode } from "react"; |
| 3 | ChevronDownIcon, | ||
| 4 | ChevronRightIcon, | ||
| 5 | CopyIcon, | ||
| 6 | FolderOpenIcon, | ||
| 7 | InfoIcon, | ||
| 8 | RefreshCwIcon, | ||
| 9 | } from "lucide-react"; | ||
| 10 | import { useCallback, useMemo, useState, type ReactNode } from "react"; | ||
| 11 | import type { | ||
| 12 | ServerProcessDiagnosticsEntry, | ||
| 13 | ServerProcessResourceHistorySummary, | ||
| 14 | ServerProcessSignal, | ||
| 15 | } from "@t3tools/contracts"; | ||
| 16 | import * as DateTime from "effect/DateTime"; | 3 | import * as DateTime from "effect/DateTime"; |
| 17 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 18 | 5 | ||
| @@ -21,15 +8,11 @@ import { cn } from "../../lib/utils"; · 2 unmodified lines | |||
| 21 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; | 8 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; |
| 22 | import { formatRelativeTime } from "../../timestampFormat"; | 9 | import { formatRelativeTime } from "../../timestampFormat"; |
| 23 | import { useServerAvailableEditors, useServerObservability } from "../../rpc/serverState"; | 10 | import { useServerAvailableEditors, useServerObservability } from "../../rpc/serverState"; |
| 24 | import { | ||
| 25 | useProcessDiagnostics, | ||
| 26 | useProcessResourceHistory, | ||
| 27 | } from "../../lib/processDiagnosticsState"; | ||
| 28 | import { useTraceDiagnostics } from "../../lib/traceDiagnosticsState"; | 11 | import { useTraceDiagnostics } from "../../lib/traceDiagnosticsState"; |
| 29 | import { Button } from "../ui/button"; | 12 | import { Button } from "../ui/button"; |
| 30 | import { ScrollArea } from "../ui/scroll-area"; | 13 | import { ScrollArea } from "../ui/scroll-area"; |
| 31 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 14 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 32 | import { toastManager } from "../ui/toast"; | 15 | import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics"; |
| 33 | import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; | 16 | import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; |
| 34 | 17 | ||
| 35 | const NUMBER_FORMAT = new Intl.NumberFormat(); | 18 | const NUMBER_FORMAT = new Intl.NumberFormat(); |
| @@ -43,18 +26,6 @@ function formatDuration(value: number): string { · 7 unmodified lines | |||
| 43 | return `${(value / 1_000).toFixed(value >= 10_000 ? 1 : 2)} s`; | 26 | return `${(value / 1_000).toFixed(value >= 10_000 ? 1 : 2)} s`; |
| 44 | } | 27 | } |
| 45 | 28 | ||
| 46 | function formatBytes(value: number): string { | ||
| 47 | if (value < 1024) return `${value} B`; | ||
| 48 | const units = ["KB", "MB", "GB"] as const; | ||
| 49 | let unitIndex = -1; | ||
| 50 | let next = value; | ||
| 51 | do { | ||
| 52 | next /= 1024; | ||
| 53 | unitIndex += 1; | ||
| 54 | } while (next >= 1024 && unitIndex < units.length - 1); | ||
| 55 | return `${next.toFixed(next >= 10 ? 1 : 2)} ${units[unitIndex]}`; | ||
| 56 | } | ||
| 57 | |||
| 58 | function formatRelative(value: DateTime.Utc | null): string { | 29 | function formatRelative(value: DateTime.Utc | null): string { |
| 59 | if (!value) return "No trace records"; | 30 | if (!value) return "No trace records"; |
| 60 | const relative = formatRelativeTime(DateTime.formatIso(value)); | 31 | const relative = formatRelativeTime(DateTime.formatIso(value)); |
| @@ -70,10 +41,6 @@ function shortenTraceId(traceId: string): string { · 9 unmodified lines | |||
| 70 | return `${traceId.slice(0, 18)}...${traceId.slice(-10)}`; | 41 | return `${traceId.slice(0, 18)}...${traceId.slice(-10)}`; |
| 71 | } | 42 | } |
| 72 | 43 | ||
| 73 | function isStaleProcessSignalMessage(message: string | undefined): boolean { | ||
| 74 | return message?.includes("not a live descendant") ?? false; | ||
| 75 | } | ||
| 76 | |||
| 77 | function StatBlock({ | 44 | function StatBlock({ |
| 78 | label, | 45 | label, |
| 79 | value, | 46 | value, |
| @@ -286,471 +253,6 @@ function TraceIdCell({ traceId }: { traceId: string }) { · 206 unmodified lines | |||
| 286 | ); | 253 | ); |
| 287 | } | 254 | } |
| 288 | 255 | ||
| 289 | function formatProcessName(command: string): string { | ||
| 290 | const firstToken = command.trim().split(/\s+/)[0]; | ||
| 291 | if (!firstToken) return command; | ||
| 292 | const normalized = firstToken.replace(/^['"]|['"]$/g, ""); | ||
| 293 | const segments = normalized.split(/[\\/]/).filter(Boolean); | ||
| 294 | return segments.at(-1) ?? normalized; | ||
| 295 | } | ||
| 296 | |||
| 297 | function formatProcessType(process: ServerProcessDiagnosticsEntry): string { | ||
| 298 | if (process.depth > 0) return "Subprocess"; | ||
| 299 | if (/\b(codex|claude|opencode|cursor)\b/i.test(process.command)) return "Agent"; | ||
| 300 | return "Process"; | ||
| 301 | } | ||
| 302 | |||
| 303 | function ProcessNameCell({ | ||
| 304 | process, | ||
| 305 | isExpanded, | ||
| 306 | onToggle, | ||
| 307 | }: { | ||
| 308 | process: ServerProcessDiagnosticsEntry; | ||
| 309 | isExpanded: boolean; | ||
| 310 | onToggle: (pid: number) => void; | ||
| 311 | }) { | ||
| 312 | const name = formatProcessName(process.command); | ||
| 313 | const hasChildren = process.childPids.length > 0; | ||
| 314 | const ChevronIcon = isExpanded ? ChevronDownIcon : ChevronRightIcon; | ||
| 315 | |||
| 316 | return ( | ||
| 317 | <div | ||
| 318 | className="grid min-w-0 grid-cols-[1.25rem_0.375rem_minmax(0,1fr)] items-center gap-2" | ||
| 319 | style={{ paddingLeft: `${Math.min(process.depth, 6) * 10}px` }} | ||
| 320 | > | ||
| 321 | {hasChildren ? ( | ||
| 322 | <button | ||
| 323 | type="button" | ||
| 324 | className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" | ||
| 325 | aria-label={isExpanded ? `Collapse ${name}` : `Expand ${name}`} | ||
| 326 | onClick={() => onToggle(process.pid)} | ||
| 327 | > | ||
| 328 | <ChevronIcon className="size-3.5" /> | ||
| 329 | </button> | ||
| 330 | ) : ( | ||
| 331 | <span className="size-5 shrink-0" aria-hidden="true" /> | ||
| 332 | )} | ||
| 333 | <span className="size-1.5 shrink-0 rounded-full bg-emerald-500/80" /> | ||
| 334 | <Tooltip> | ||
| 335 | <TooltipTrigger | ||
| 336 | render={<span className="min-w-0 truncate font-medium text-foreground">{name}</span>} | ||
| 337 | /> | ||
| 338 | <TooltipPopup | ||
| 339 | side="top" | ||
| 340 | className="max-w-[min(440px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px] leading-relaxed text-wrap" | ||
| 341 | > | ||
| 342 | {process.command} | ||
| 343 | </TooltipPopup> | ||
| 344 | </Tooltip> | ||
| 345 | </div> | ||
| 346 | ); | ||
| 347 | } | ||
| 348 | |||
| 349 | function ProcessSignalActions({ | ||
| 350 | process, | ||
| 351 | isSignaling, | ||
| 352 | onSignal, | ||
| 353 | }: { | ||
| 354 | process: ServerProcessDiagnosticsEntry; | ||
| 355 | isSignaling: boolean; | ||
| 356 | onSignal: (pid: number, signal: ServerProcessSignal) => void; | ||
| 357 | }) { | ||
| 358 | return ( | ||
| 359 | <div className="flex items-center justify-end gap-1.5"> | ||
| 360 | <Tooltip> | ||
| 361 | <TooltipTrigger | ||
| 362 | render={ | ||
| 363 | <button | ||
| 364 | type="button" | ||
| 365 | disabled={isSignaling} | ||
| 366 | className="text-[11px] font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:pointer-events-none disabled:opacity-50" | ||
| 367 | onClick={() => onSignal(process.pid, "SIGINT")} | ||
| 368 | > | ||
| 369 | INT | ||
| 370 | </button> | ||
| 371 | } | ||
| 372 | /> | ||
| 373 | <TooltipPopup side="top">Send SIGINT</TooltipPopup> | ||
| 374 | </Tooltip> | ||
| 375 | <Tooltip> | ||
| 376 | <TooltipTrigger | ||
| 377 | render={ | ||
| 378 | <button | ||
| 379 | type="button" | ||
| 380 | disabled={isSignaling} | ||
| 381 | className="text-[11px] font-medium text-destructive underline-offset-2 hover:underline disabled:pointer-events-none disabled:opacity-50" | ||
| 382 | onClick={() => onSignal(process.pid, "SIGKILL")} | ||
| 383 | > | ||
| 384 | KILL | ||
| 385 | </button> | ||
| 386 | } | ||
| 387 | /> | ||
| 388 | <TooltipPopup side="top">Send SIGKILL</TooltipPopup> | ||
| 389 | </Tooltip> | ||
| 390 | </div> | ||
| 391 | ); | ||
| 392 | } | ||
| 393 | |||
| 394 | function ProcessDiagnosticsTable({ | ||
| 395 | processes, | ||
| 396 | signalingPid, | ||
| 397 | onSignal, | ||
| 398 | emptyLabel, | ||
| 399 | }: { | ||
| 400 | processes: ReadonlyArray<ServerProcessDiagnosticsEntry>; | ||
| 401 | signalingPid: number | null; | ||
| 402 | onSignal: (pid: number, signal: ServerProcessSignal) => void; | ||
| 403 | emptyLabel?: string; | ||
| 404 | }) { | ||
| 405 | const [collapsedPids, setCollapsedPids] = useState<ReadonlySet<number>>(() => new Set()); | ||
| 406 | const visibleProcesses = useMemo(() => { | ||
| 407 | const visible: ServerProcessDiagnosticsEntry[] = []; | ||
| 408 | let hiddenChildDepth: number | null = null; | ||
| 409 | |||
| 410 | for (const process of processes) { | ||
| 411 | if (hiddenChildDepth !== null) { | ||
| 412 | if (process.depth > hiddenChildDepth) continue; | ||
| 413 | hiddenChildDepth = null; | ||
| 414 | } | ||
| 415 | |||
| 416 | visible.push(process); | ||
| 417 | if (collapsedPids.has(process.pid)) { | ||
| 418 | hiddenChildDepth = process.depth; | ||
| 419 | } | ||
| 420 | } | ||
| 421 | |||
| 422 | return visible; | ||
| 423 | }, [collapsedPids, processes]); | ||
| 424 | |||
| 425 | const toggleProcess = useCallback((pid: number) => { | ||
| 426 | setCollapsedPids((previous) => { | ||
| 427 | const next = new Set(previous); | ||
| 428 | if (next.has(pid)) { | ||
| 429 | next.delete(pid); | ||
| 430 | } else { | ||
| 431 | next.add(pid); | ||
| 432 | } | ||
| 433 | return next; | ||
| 434 | }); | ||
| 435 | }, []); | ||
| 436 | |||
| 437 | return ( | ||
| 438 | <ScrollArea | ||
| 439 | chainVerticalScroll | ||
| 440 | scrollFade | ||
| 441 | hideScrollbars | ||
| 442 | className="max-h-[min(64vh,44rem)] w-full max-w-full rounded-none border-t border-border/60" | ||
| 443 | > | ||
| 444 | <table className="w-full min-w-[1040px] table-fixed text-left text-xs"> | ||
| 445 | <colgroup> | ||
| 446 | <col className="w-[24%]" /> | ||
| 447 | <col className="w-[8%]" /> | ||
| 448 | <col className="w-[10%]" /> | ||
| 449 | <col className="w-[33%]" /> | ||
| 450 | <col className="w-[8%]" /> | ||
| 451 | <col className="w-[11%]" /> | ||
| 452 | <col className="w-[6%]" /> | ||
| 453 | </colgroup> | ||
| 454 | <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-[11px] uppercase tracking-[0.08em] text-muted-foreground/70"> | ||
| 455 | <tr> | ||
| 456 | <th className="px-4 py-2 font-semibold sm:pl-5">Name</th> | ||
| 457 | <th className="px-3 py-2 text-right font-semibold">CPU</th> | ||
| 458 | <th className="px-3 py-2 text-right font-semibold">Memory</th> | ||
| 459 | <th className="px-3 py-2 font-semibold">Command</th> | ||
| 460 | <th className="px-3 py-2 text-right font-semibold">PID</th> | ||
| 461 | <th className="px-3 py-2 font-semibold">Type</th> | ||
| 462 | <th className="p-2 text-right font-semibold sm:pr-4">Kill</th> | ||
| 463 | </tr> | ||
| 464 | </thead> | ||
| 465 | <tbody className="divide-y divide-border/50"> | ||
| 466 | {visibleProcesses.length === 0 ? ( | ||
| 467 | <tr> | ||
| 468 | <td colSpan={7} className="px-4 py-4 text-xs text-muted-foreground sm:px-5"> | ||
| 469 | {emptyLabel ?? "No live descendant processes found."} | ||
| 470 | </td> | ||
| 471 | </tr> | ||
| 472 | ) : null} | ||
| 473 | {visibleProcesses.map((process) => ( | ||
| 474 | <tr key={process.pid} className="hover:bg-muted/20"> | ||
| 475 | <td className="px-4 py-2 align-middle sm:pl-5"> | ||
| 476 | <ProcessNameCell | ||
| 477 | process={process} | ||
| 478 | isExpanded={!collapsedPids.has(process.pid)} | ||
| 479 | onToggle={toggleProcess} | ||
| 480 | /> | ||
| 481 | </td> | ||
| 482 | <td className="px-3 py-2 text-right align-middle font-mono tabular-nums"> | ||
| 483 | {process.cpuPercent.toFixed(1)}% | ||
| 484 | </td> | ||
| 485 | <td className="px-3 py-2 text-right align-middle font-mono tabular-nums"> | ||
| 486 | {formatBytes(process.rssBytes)} | ||
| 487 | </td> | ||
| 488 | <td className="px-3 py-2 align-middle text-muted-foreground"> | ||
| 489 | <Tooltip> | ||
| 490 | <TooltipTrigger | ||
| 491 | render={<span className="block truncate">{process.command}</span>} | ||
| 492 | /> | ||
| 493 | <TooltipPopup | ||
| 494 | side="top" | ||
| 495 | className="max-w-[min(440px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px] leading-relaxed text-wrap" | ||
| 496 | > | ||
| 497 | {process.command} | ||
| 498 | </TooltipPopup> | ||
| 499 | </Tooltip> | ||
| 500 | </td> | ||
| 501 | <td className="px-3 py-2 text-right align-middle font-mono tabular-nums text-muted-foreground"> | ||
| 502 | {process.pid} | ||
| 503 | </td> | ||
| 504 | <td className="truncate px-3 py-2 align-middle text-muted-foreground"> | ||
| 505 | {formatProcessType(process)} | ||
| 506 | </td> | ||
| 507 | <td className="p-2 align-middle sm:pr-4"> | ||
| 508 | <ProcessSignalActions | ||
| 509 | process={process} | ||
| 510 | isSignaling={signalingPid === process.pid} | ||
| 511 | onSignal={onSignal} | ||
| 512 | /> | ||
| 513 | </td> | ||
| 514 | </tr> | ||
| 515 | ))} | ||
| 516 | </tbody> | ||
| 517 | </table> | ||
| 518 | </ScrollArea> | ||
| 519 | ); | ||
| 520 | } | ||
| 521 | |||
| 522 | const RESOURCE_HISTORY_WINDOWS = [ | ||
| 523 | { label: "5m", windowMs: 5 * 60_000, bucketMs: 30_000 }, | ||
| 524 | { label: "15m", windowMs: 15 * 60_000, bucketMs: 60_000 }, | ||
| 525 | { label: "30m", windowMs: 30 * 60_000, bucketMs: 2 * 60_000 }, | ||
| 526 | { label: "1h", windowMs: 60 * 60_000, bucketMs: 5 * 60_000 }, | ||
| 527 | ] as const; | ||
| 528 | |||
| 529 | function formatCpuTime(seconds: number): string { | ||
| 530 | if (seconds < 60) return `${seconds.toFixed(seconds >= 10 ? 1 : 2)}s`; | ||
| 531 | const minutes = seconds / 60; | ||
| 532 | if (minutes < 60) return `${minutes.toFixed(minutes >= 10 ? 1 : 2)}m`; | ||
| 533 | return `${(minutes / 60).toFixed(2)}h`; | ||
| 534 | } | ||
| 535 | |||
| 536 | function formatShortProcessName(command: string): string { | ||
| 537 | const name = formatProcessName(command); | ||
| 538 | return name.length > 42 ? `${name.slice(0, 39)}...` : name; | ||
| 539 | } | ||
| 540 | |||
| 541 | function ResourceHistoryProcessNameCell({ | ||
| 542 | process, | ||
| 543 | visualDepth, | ||
| 544 | }: { | ||
| 545 | process: ServerProcessResourceHistorySummary; | ||
| 546 | visualDepth: number; | ||
| 547 | }) { | ||
| 548 | const name = formatShortProcessName(process.command); | ||
| 549 | |||
| 550 | return ( | ||
| 551 | <div | ||
| 552 | className="grid min-w-0 grid-cols-[1.25rem_0.375rem_minmax(0,1fr)] items-center gap-2" | ||
| 553 | style={{ paddingLeft: `${Math.min(visualDepth, 6) * 10}px` }} | ||
| 554 | aria-label={`${process.isServerRoot ? "Root" : "Child"} process ${name}`} | ||
| 555 | > | ||
| 556 | <span className="size-5 shrink-0" aria-hidden="true" /> | ||
| 557 | <span | ||
| 558 | className={cn( | ||
| 559 | "size-1.5 shrink-0 rounded-full", | ||
| 560 | process.isServerRoot ? "bg-amber-500/90" : "bg-emerald-500/80", | ||
| 561 | )} | ||
| 562 | /> | ||
| 563 | <Tooltip> | ||
| 564 | <TooltipTrigger | ||
| 565 | render={<span className="min-w-0 truncate font-medium text-foreground">{name}</span>} | ||
| 566 | /> | ||
| 567 | <TooltipPopup | ||
| 568 | side="top" | ||
| 569 | className="max-w-[min(440px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px] leading-relaxed text-wrap" | ||
| 570 | > | ||
| 571 | {process.command} | ||
| 572 | </TooltipPopup> | ||
| 573 | </Tooltip> | ||
| 574 | </div> | ||
| 575 | ); | ||
| 576 | } | ||
| 577 | |||
| 578 | function ProcessResourceHistoryChart({ | ||
| 579 | buckets, | ||
| 580 | }: { | ||
| 581 | buckets: ReadonlyArray<{ | ||
| 582 | readonly startedAt: DateTime.Utc; | ||
| 583 | readonly avgCpuPercent: number; | ||
| 584 | readonly maxCpuPercent: number; | ||
| 585 | }>; | ||
| 586 | }) { | ||
| 587 | const maxCpuPercent = Math.max(1, ...buckets.map((bucket) => bucket.maxCpuPercent)); | ||
| 588 | |||
| 589 | return ( | ||
| 590 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | ||
| 591 | <div className="flex h-28 items-end gap-1 overflow-hidden rounded-sm bg-muted/10 p-2"> | ||
| 592 | {buckets.map((bucket) => { | ||
| 593 | const peakHeight = Math.max(2, (bucket.maxCpuPercent / maxCpuPercent) * 100); | ||
| 594 | const averageHeight = Math.max(2, (bucket.avgCpuPercent / maxCpuPercent) * 100); | ||
| 595 | return ( | ||
| 596 | <Tooltip key={DateTime.formatIso(bucket.startedAt)}> | ||
| 597 | <TooltipTrigger | ||
| 598 | render={ | ||
| 599 | <div className="flex h-full min-w-1 flex-1 items-end"> | ||
| 600 | <div | ||
| 601 | className="relative h-full w-full" | ||
| 602 | aria-label={`Average CPU ${bucket.avgCpuPercent.toFixed(1)}%, peak CPU ${bucket.maxCpuPercent.toFixed(1)}%`} | ||
| 603 | > | ||
| 604 | <div | ||
| 605 | className="absolute inset-x-0 bottom-0 rounded-t-sm bg-foreground/15 transition-colors" | ||
| 606 | style={{ height: `${peakHeight}%` }} | ||
| 607 | /> | ||
| 608 | <div | ||
| 609 | className="absolute inset-x-0 bottom-0 rounded-t-sm bg-foreground/60 transition-colors" | ||
| 610 | style={{ height: `${averageHeight}%` }} | ||
| 611 | /> | ||
| 612 | </div> | ||
| 613 | </div> | ||
| 614 | } | ||
| 615 | /> | ||
| 616 | <TooltipPopup side="top"> | ||
| 617 | Avg {bucket.avgCpuPercent.toFixed(1)}%, peak {bucket.maxCpuPercent.toFixed(1)}% | ||
| 618 | </TooltipPopup> | ||
| 619 | </Tooltip> | ||
| 620 | ); | ||
| 621 | })} | ||
| 622 | </div> | ||
| 623 | </div> | ||
| 624 | ); | ||
| 625 | } | ||
| 626 | |||
| 627 | function ResourceHistoryWindowSelector({ | ||
| 628 | selectedWindowMs, | ||
| 629 | onSelect, | ||
| 630 | }: { | ||
| 631 | selectedWindowMs: number; | ||
| 632 | onSelect: (windowMs: number) => void; | ||
| 633 | }) { | ||
| 634 | return ( | ||
| 635 | <div className="flex items-center rounded-md border border-border/60 p-0.5"> | ||
| 636 | {RESOURCE_HISTORY_WINDOWS.map((option) => ( | ||
| 637 | <button | ||
| 638 | key={option.windowMs} | ||
| 639 | type="button" | ||
| 640 | className={cn( | ||
| 641 | "h-6 rounded-sm px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground", | ||
| 642 | selectedWindowMs === option.windowMs && "bg-muted text-foreground", | ||
| 643 | )} | ||
| 644 | onClick={() => onSelect(option.windowMs)} | ||
| 645 | > | ||
| 646 | {option.label} | ||
| 647 | </button> | ||
| 648 | ))} | ||
| 649 | </div> | ||
| 650 | ); | ||
| 651 | } | ||
| 652 | |||
| 653 | function ProcessResourceHistoryTable({ | ||
| 654 | processes, | ||
| 655 | emptyLabel, | ||
| 656 | }: { | ||
| 657 | processes: ReadonlyArray<ServerProcessResourceHistorySummary>; | ||
| 658 | emptyLabel: string; | ||
| 659 | }) { | ||
| 660 | const shallowestChildDepth = processes.reduce<number | null>((minDepth, process) => { | ||
| 661 | if (process.isServerRoot) return minDepth; | ||
| 662 | return minDepth === null ? process.depth : Math.min(minDepth, process.depth); | ||
| 663 | }, null); | ||
| 664 | |||
| 665 | return ( | ||
| 666 | <ScrollArea | ||
| 667 | chainVerticalScroll | ||
| 668 | scrollFade | ||
| 669 | hideScrollbars | ||
| 670 | className="max-h-[min(64vh,44rem)] w-full max-w-full border-t border-border/60" | ||
| 671 | > | ||
| 672 | <table className="w-full min-w-[980px] table-fixed text-left text-xs"> | ||
| 673 | <colgroup> | ||
| 674 | <col className="w-[24%]" /> | ||
| 675 | <col className="w-[10%]" /> | ||
| 676 | <col className="w-[10%]" /> | ||
| 677 | <col className="w-[10%]" /> | ||
| 678 | <col className="w-[10%]" /> | ||
| 679 | <col className="w-[10%]" /> | ||
| 680 | <col className="w-[16%]" /> | ||
| 681 | <col className="w-[10%]" /> | ||
| 682 | </colgroup> | ||
| 683 | <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-[11px] uppercase tracking-[0.08em] text-muted-foreground/70"> | ||
| 684 | <tr> | ||
| 685 | <th className="px-4 py-2 font-semibold sm:pl-5">Process</th> | ||
| 686 | <th className="px-3 py-2 text-right font-semibold">CPU Time</th> | ||
| 687 | <th className="px-3 py-2 text-right font-semibold">Current</th> | ||
| 688 | <th className="px-3 py-2 text-right font-semibold">Average</th> | ||
| 689 | <th className="px-3 py-2 text-right font-semibold">Peak</th> | ||
| 690 | <th className="px-3 py-2 text-right font-semibold">Max Mem</th> | ||
| 691 | <th className="px-3 py-2 font-semibold">Command</th> | ||
| 692 | <th className="px-3 py-2 text-right font-semibold sm:pr-5">PID</th> | ||
| 693 | </tr> | ||
| 694 | </thead> | ||
| 695 | <tbody className="divide-y divide-border/50"> | ||
| 696 | {processes.length === 0 ? ( | ||
| 697 | <tr> | ||
| 698 | <td colSpan={8} className="px-4 py-4 text-xs text-muted-foreground sm:px-5"> | ||
| 699 | {emptyLabel} | ||
| 700 | </td> | ||
| 701 | </tr> | ||
| 702 | ) : null} | ||
| 703 | {processes.map((process) => ( | ||
| 704 | <tr key={process.processKey} className="hover:bg-muted/20"> | ||
| 705 | <td className="px-4 py-2 align-middle sm:pl-5"> | ||
| 706 | <ResourceHistoryProcessNameCell | ||
| 707 | process={process} | ||
| 708 | visualDepth={ | ||
| 709 | process.isServerRoot || shallowestChildDepth === null | ||
| 710 | ? 0 | ||
| 711 | : Math.max(1, process.depth - shallowestChildDepth + 1) | ||
| 712 | } | ||
| 713 | /> | ||
| 714 | </td> | ||
| 715 | <td className="px-3 py-2 text-right align-middle font-mono tabular-nums"> | ||
| 716 | {formatCpuTime(process.cpuSecondsApprox)} | ||
| 717 | </td> | ||
| 718 | <td className="px-3 py-2 text-right align-middle font-mono tabular-nums"> | ||
| 719 | {process.currentCpuPercent.toFixed(1)}% | ||
| 720 | </td> | ||
| 721 | <td className="px-3 py-2 text-right align-middle font-mono tabular-nums"> | ||
| 722 | {process.avgCpuPercent.toFixed(1)}% | ||
| 723 | </td> | ||
| 724 | <td className="px-3 py-2 text-right align-middle font-mono tabular-nums"> | ||
| 725 | {process.maxCpuPercent.toFixed(1)}% | ||
| 726 | </td> | ||
| 727 | <td className="px-3 py-2 text-right align-middle font-mono tabular-nums"> | ||
| 728 | {formatBytes(process.maxRssBytes)} | ||
| 729 | </td> | ||
| 730 | <td className="px-3 py-2 align-middle text-muted-foreground"> | ||
| 731 | <Tooltip> | ||
| 732 | <TooltipTrigger | ||
| 733 | render={<span className="block truncate">{process.command}</span>} | ||
| 734 | /> | ||
| 735 | <TooltipPopup | ||
| 736 | side="top" | ||
| 737 | className="max-w-[min(440px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px] leading-relaxed text-wrap" | ||
| 738 | > | ||
| 739 | {process.command} | ||
| 740 | </TooltipPopup> | ||
| 741 | </Tooltip> | ||
| 742 | </td> | ||
| 743 | <td className="px-3 py-2 text-right align-middle font-mono tabular-nums text-muted-foreground sm:pr-5"> | ||
| 744 | {process.pid} | ||
| 745 | </td> | ||
| 746 | </tr> | ||
| 747 | ))} | ||
| 748 | </tbody> | ||
| 749 | </table> | ||
| 750 | </ScrollArea> | ||
| 751 | ); | ||
| 752 | } | ||
| 753 | |||
| 754 | function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) { | 256 | function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) { |
| 755 | useRelativeTimeTick(); | 257 | useRelativeTimeTick(); |
| 756 | const relative = checkedAt ? formatRelativeTime(DateTime.formatIso(checkedAt)) : null; | 258 | const relative = checkedAt ? formatRelativeTime(DateTime.formatIso(checkedAt)) : null; |
| @@ -805,29 +307,9 @@ function DiagnosticsRefreshButton({ · 48 unmodified lines | |||
| 805 | export function DiagnosticsSettingsPanel() { | 307 | export function DiagnosticsSettingsPanel() { |
| 806 | const observability = useServerObservability(); | 308 | const observability = useServerObservability(); |
| 807 | const availableEditors = useServerAvailableEditors(); | 309 | const availableEditors = useServerAvailableEditors(); |
| 808 | const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000); | ||
| 809 | const selectedResourceWindow = | ||
| 810 | RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? | ||
| 811 | RESOURCE_HISTORY_WINDOWS[1]; | ||
| 812 | const { data, error, isPending, refresh } = useTraceDiagnostics(); | 310 | const { data, error, isPending, refresh } = useTraceDiagnostics(); |
| 813 | const { | ||
| 814 | data: processData, | ||
| 815 | error: processError, | ||
| 816 | isPending: isProcessPending, | ||
| 817 | refresh: refreshProcesses, | ||
| 818 | } = useProcessDiagnostics(); | ||
| 819 | const { | ||
| 820 | data: resourceData, | ||
| 821 | error: resourceError, | ||
| 822 | isPending: isResourcePending, | ||
| 823 | refresh: refreshResources, | ||
| 824 | } = useProcessResourceHistory({ | ||
| 825 | windowMs: selectedResourceWindow.windowMs, | ||
| 826 | bucketMs: selectedResourceWindow.bucketMs, | ||
| 827 | }); | ||
| 828 | const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false); | 311 | const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false); |
| 829 | const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState<string | null>(null); | 312 | const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState<string | null>(null); |
| 830 | const [signalingPid, setSignalingPid] = useState<number | null>(null); | ||
| 831 | 313 | ||
| 832 | const openLogsDirectory = useCallback(() => { | 314 | const openLogsDirectory = useCallback(() => { |
| 833 | const logsDirectoryPath = observability?.logsDirectoryPath ?? null; | 315 | const logsDirectoryPath = observability?.logsDirectoryPath ?? null; |
| @@ -854,58 +336,7 @@ export function DiagnosticsSettingsPanel() { · 20 unmodified lines | |||
| 854 | }, [availableEditors, observability?.logsDirectoryPath]); | 336 | }, [availableEditors, observability?.logsDirectoryPath]); |
| 855 | 337 | ||
| 856 | const isInitialLoading = isPending && data === null; | 338 | const isInitialLoading = isPending && data === null; |
| 857 | const isProcessInitialLoading = isProcessPending && processData === null; | ||
| 858 | const signalProcess = useCallback( | ||
| 859 | (pid: number, signal: ServerProcessSignal) => { | ||
| 860 | if ( | ||
| 861 | signal === "SIGKILL" && | ||
| 862 | !window.confirm(`Send SIGKILL to process ${pid}? This cannot be handled by the process.`) | ||
| 863 | ) { | ||
| 864 | return; | ||
| 865 | } | ||
| 866 | |||
| 867 | setSignalingPid(pid); | ||
| 868 | void ensureLocalApi() | ||
| 869 | .server.signalProcess({ pid, signal }) | ||
| 870 | .then((result) => { | ||
| 871 | if (!result.signaled) { | ||
| 872 | const message = Option.getOrUndefined(result.message); | ||
| 873 | refreshProcesses(); | ||
| 874 | if (isStaleProcessSignalMessage(message)) { | ||
| 875 | toastManager.add({ | ||
| 876 | type: "info", | ||
| 877 | title: "Process already exited", | ||
| 878 | description: | ||
| 879 | "The process is not a child of the T3 Server. It might already have exited.", | ||
| 880 | }); | ||
| 881 | return; | ||
| 882 | } | ||
| 883 | 339 | ||
| 884 | toastManager.add({ | ||
| 885 | type: "error", | ||
| 886 | title: `Could not send ${signal}`, | ||
| 887 | description: message ?? `Failed to send ${signal}.`, | ||
| 888 | }); | ||
| 889 | return; | ||
| 890 | } | ||
| 891 | refreshProcesses(); | ||
| 892 | }) | ||
| 893 | .catch((error: unknown) => { | ||
| 894 | toastManager.add({ | ||
| 895 | type: "error", | ||
| 896 | title: `Could not send ${signal}`, | ||
| 897 | description: error instanceof Error ? error.message : `Failed to send ${signal}.`, | ||
| 898 | }); | ||
| 899 | }) | ||
| 900 | .finally(() => { | ||
| 901 | setSignalingPid(null); | ||
| 902 | }); | ||
| 903 | }, | ||
| 904 | [refreshProcesses], | ||
| 905 | ); | ||
| 906 | |||
| 907 | const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null; | ||
| 908 | const processResourceError = resourceData ? Option.getOrNull(resourceData.error) : null; | ||
| 909 | const traceDiagnosticsError = data ? Option.getOrNull(data.error) : null; | 340 | const traceDiagnosticsError = data ? Option.getOrNull(data.error) : null; |
| 910 | const traceDiagnosticsPartialFailure = data | 341 | const traceDiagnosticsPartialFailure = data |
| 911 | ? Option.getOrElse(data.partialFailure, () => false) | 342 | ? Option.getOrElse(data.partialFailure, () => false) |
| @@ -913,130 +344,7 @@ export function DiagnosticsSettingsPanel() { · 1 unmodified line | |||
| 913 | 344 | ||
| 914 | return ( | 345 | return ( |
| 915 | <SettingsPageContainer> | 346 | <SettingsPageContainer> |
| 916 | <SettingsSection | 347 | <ResourceTelemetryDiagnostics /> |
| 917 | title="Live Processes" | ||
| 918 | headerAction={ | ||
| 919 | <div className="flex items-center gap-1.5"> | ||
| 920 | <DiagnosticsLastChecked checkedAt={processData?.readAt ?? null} /> | ||
| 921 | <DiagnosticsRefreshButton | ||
| 922 | isPending={isProcessPending} | ||
| 923 | label="Refresh process diagnostics" | ||
| 924 | onClick={refreshProcesses} | ||
| 925 | /> | ||
| 926 | </div> | ||
| 927 | } | ||
| 928 | > | ||
| 929 | <StatsGrid> | ||
| 930 | <StatBlock | ||
| 931 | label="Child Processes" | ||
| 932 | value={processData ? formatCount(processData.processCount) : "..."} | ||
| 933 | /> | ||
| 934 | <StatBlock | ||
| 935 | label="CPU" | ||
| 936 | value={processData ? `${processData.totalCpuPercent.toFixed(1)}%` : "..."} | ||
| 937 | tooltip="Total CPU across live child processes of the current server process. The desktop shell and other parent processes are not included." | ||
| 938 | /> | ||
| 939 | <StatBlock | ||
| 940 | label="Memory" | ||
| 941 | value={processData ? formatBytes(processData.totalRssBytes) : "..."} | ||
| 942 | tooltip="Total resident memory across live child processes of the current server process. The desktop shell and other parent processes are not included." | ||
| 943 | /> | ||
| 944 | <StatBlock | ||
| 945 | label="Server PID" | ||
| 946 | value={processData ? String(processData.serverPid) : "..."} | ||
| 947 | /> | ||
| 948 | </StatsGrid> | ||
| 949 | {processDiagnosticsError || processError ? ( | ||
| 950 | <div className="space-y-2 border-t border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-5"> | ||
| 951 | {processDiagnosticsError ? ( | ||
| 952 | <div className="flex items-start gap-2 text-destructive"> | ||
| 953 | <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" /> | ||
| 954 | <span>{processDiagnosticsError.message}</span> | ||
| 955 | </div> | ||
| 956 | ) : null} | ||
| 957 | {processError ? ( | ||
| 958 | <div className="flex items-start gap-2 text-destructive"> | ||
| 959 | <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" /> | ||
| 960 | <span>{processError}</span> | ||
| 961 | </div> | ||
| 962 | ) : null} | ||
| 963 | </div> | ||
| 964 | ) : null} | ||
| 965 | <ProcessDiagnosticsTable | ||
| 966 | processes={processData?.processes ?? []} | ||
| 967 | signalingPid={signalingPid} | ||
| 968 | onSignal={signalProcess} | ||
| 969 | emptyLabel={ | ||
| 970 | isProcessInitialLoading | ||
| 971 | ? "Loading live processes..." | ||
| 972 | : "No live descendant processes found." | ||
| 973 | } | ||
| 974 | /> | ||
| 975 | </SettingsSection> | ||
| 976 | |||
| 977 | <SettingsSection | ||
| 978 | title="Resource History" | ||
| 979 | headerAction={ | ||
| 980 | <div className="flex items-center gap-1.5"> | ||
| 981 | <ResourceHistoryWindowSelector | ||
| 982 | selectedWindowMs={resourceWindowMs} | ||
| 983 | onSelect={setResourceWindowMs} | ||
| 984 | /> | ||
| 985 | <DiagnosticsLastChecked checkedAt={resourceData?.readAt ?? null} /> | ||
| 986 | <DiagnosticsRefreshButton | ||
| 987 | isPending={isResourcePending} | ||
| 988 | label="Refresh resource history" | ||
| 989 | onClick={refreshResources} | ||
| 990 | /> | ||
| 991 | </div> | ||
| 992 | } | ||
| 993 | > | ||
| 994 | <StatsGrid> | ||
| 995 | <StatBlock | ||
| 996 | label="CPU Time" | ||
| 997 | value={resourceData ? formatCpuTime(resourceData.totalCpuSecondsApprox) : "..."} | ||
| 998 | tooltip="Approximate active CPU time for the T3 server root process and its descendants during the selected window. It grows only while sampled processes use CPU and older samples leave as the window moves." | ||
| 999 | /> | ||
| 1000 | <StatBlock | ||
| 1001 | label="Samples" | ||
| 1002 | value={resourceData ? formatCount(resourceData.retainedSampleCount) : "..."} | ||
| 1003 | tooltip="In-memory process samples retained by the server. This resets when the server restarts." | ||
| 1004 | /> | ||
| 1005 | <StatBlock | ||
| 1006 | label="Interval" | ||
| 1007 | value={resourceData ? formatDuration(resourceData.sampleIntervalMs) : "..."} | ||
| 1008 | /> | ||
| 1009 | <StatBlock | ||
| 1010 | label="Processes" | ||
| 1011 | value={resourceData ? formatCount(resourceData.topProcesses.length) : "..."} | ||
| 1012 | /> | ||
| 1013 | </StatsGrid> | ||
| 1014 | {processResourceError || resourceError ? ( | ||
| 1015 | <div className="space-y-2 border-t border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-5"> | ||
| 1016 | {processResourceError ? ( | ||
| 1017 | <div className="flex items-start gap-2 text-destructive"> | ||
| 1018 | <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" /> | ||
| 1019 | <span>{processResourceError.message}</span> | ||
| 1020 | </div> | ||
| 1021 | ) : null} | ||
| 1022 | {resourceError ? ( | ||
| 1023 | <div className="flex items-start gap-2 text-destructive"> | ||
| 1024 | <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" /> | ||
| 1025 | <span>{resourceError}</span> | ||
| 1026 | </div> | ||
| 1027 | ) : null} | ||
| 1028 | </div> | ||
| 1029 | ) : null} | ||
| 1030 | <ProcessResourceHistoryChart buckets={resourceData?.buckets ?? []} /> | ||
| 1031 | <ProcessResourceHistoryTable | ||
| 1032 | processes={resourceData?.topProcesses ?? []} | ||
| 1033 | emptyLabel={ | ||
| 1034 | isResourcePending && resourceData === null | ||
| 1035 | ? "Collecting process resource samples..." | ||
| 1036 | : "No process resource samples found for this window." | ||
| 1037 | } | ||
| 1038 | /> | ||
| 1039 | </SettingsSection> | ||
| 1040 | 348 | ||
| 1041 | <SettingsSection | 349 | <SettingsSection |
| 1042 | title="Trace Diagnostics" | 350 | title="Trace Diagnostics" |
| @@ -0,0 +1,1151 @@ | |||
| 1 | import { | ||
| 2 | ActivityIcon, | ||
| 3 | AlertTriangleIcon, | ||
| 4 | BatteryIcon, | ||
| 5 | ChevronDownIcon, | ||
| 6 | ChevronRightIcon, | ||
| 7 | CpuIcon, | ||
| 8 | DatabaseIcon, | ||
| 9 | GaugeIcon, | ||
| 10 | HardDriveIcon, | ||
| 11 | MemoryStickIcon, | ||
| 12 | RefreshCwIcon, | ||
| 13 | RotateCcwIcon, | ||
| 14 | } from "lucide-react"; | ||
| 15 | import type { | ||
| 16 | BackgroundBooleanState, | ||
| 17 | ResourceAttributionEntry, | ||
| 18 | ResourceTelemetryAggregate, | ||
| 19 | ResourceTelemetryHistoryBucket, | ||
| 20 | ResourceTelemetryIoSemantics, | ||
| 21 | ResourceTelemetryProcess, | ||
| 22 | ResourceTelemetryProcessCategory, | ||
| 23 | ResourceTelemetryProcessSummary, | ||
| 24 | ResourceTelemetrySourceHealth, | ||
| 25 | ResourceTelemetrySourceStatus, | ||
| 26 | ServerProcessSignal, | ||
| 27 | } from "@t3tools/contracts"; | ||
| 28 | import * as DateTime from "effect/DateTime"; | ||
| 29 | import * as Option from "effect/Option"; | ||
| 30 | import { useCallback, useMemo, useState, type ReactNode } from "react"; | ||
| 31 | |||
| 32 | import { ensureLocalApi } from "../../localApi"; | ||
| 33 | import { | ||
| 34 | useResourceTelemetry, | ||
| 35 | useResourceTelemetryHistory, | ||
| 36 | } from "../../lib/resourceTelemetryState"; | ||
| 37 | import { cn } from "../../lib/utils"; | ||
| 38 | import { formatRelativeTime } from "../../timestampFormat"; | ||
| 39 | import { Button } from "../ui/button"; | ||
| 40 | import { ScrollArea } from "../ui/scroll-area"; | ||
| 41 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 42 | import { toastManager } from "../ui/toast"; | ||
| 43 | import { SettingsSection, useRelativeTimeTick } from "./settingsLayout"; | ||
| 44 | |||
| 45 | const HISTORY_WINDOWS = [ | ||
| 46 | { label: "5m", windowMs: 5 * 60_000, bucketMs: 15_000 }, | ||
| 47 | { label: "15m", windowMs: 15 * 60_000, bucketMs: 30_000 }, | ||
| 48 | { label: "30m", windowMs: 30 * 60_000, bucketMs: 60_000 }, | ||
| 49 | { label: "1h", windowMs: 60 * 60_000, bucketMs: 2 * 60_000 }, | ||
| 50 | ] as const; | ||
| 51 | |||
| 52 | function formatBytes(value: number): string { | ||
| 53 | if (value < 1_024) return `${Math.round(value)} B`; | ||
| 54 | const units = ["KB", "MB", "GB", "TB"] as const; | ||
| 55 | let next = value; | ||
| 56 | let unitIndex = -1; | ||
| 57 | do { | ||
| 58 | next /= 1_024; | ||
| 59 | unitIndex += 1; | ||
| 60 | } while (next >= 1_024 && unitIndex < units.length - 1); | ||
| 61 | return `${next.toFixed(next >= 100 ? 0 : next >= 10 ? 1 : 2)} ${units[unitIndex]}`; | ||
| 62 | } | ||
| 63 | |||
| 64 | function formatRate(value: number): string { | ||
| 65 | return `${formatBytes(value)}/s`; | ||
| 66 | } | ||
| 67 | |||
| 68 | function formatCpuTime(valueMs: number): string { | ||
| 69 | const seconds = valueMs / 1_000; | ||
| 70 | if (seconds < 60) return `${seconds.toFixed(seconds >= 10 ? 1 : 2)}s`; | ||
| 71 | const minutes = seconds / 60; | ||
| 72 | if (minutes < 60) return `${minutes.toFixed(minutes >= 10 ? 1 : 2)}m`; | ||
| 73 | return `${(minutes / 60).toFixed(2)}h`; | ||
| 74 | } | ||
| 75 | |||
| 76 | function formatDurationMicros(value: number): string { | ||
| 77 | if (value < 1_000) return `${Math.round(value)} µs`; | ||
| 78 | if (value < 1_000_000) return `${(value / 1_000).toFixed(2)} ms`; | ||
| 79 | return `${(value / 1_000_000).toFixed(2)} s`; | ||
| 80 | } | ||
| 81 | |||
| 82 | function processIdentityKey(process: ResourceTelemetryProcess): string { | ||
| 83 | return `${process.identity.pid}:${process.identity.startTimeMs}`; | ||
| 84 | } | ||
| 85 | |||
| 86 | function processSummaryIdentityKey(process: ResourceTelemetryProcessSummary): string { | ||
| 87 | return `${process.identity.pid}:${process.identity.startTimeMs}`; | ||
| 88 | } | ||
| 89 | |||
| 90 | function formatProcessName(process: Pick<ResourceTelemetryProcess, "command" | "name">): string { | ||
| 91 | if (process.name.trim()) return process.name; | ||
| 92 | const firstToken = process.command.trim().split(/\s+/)[0] ?? process.command; | ||
| 93 | const normalized = firstToken.replace(/^['"]|['"]$/g, ""); | ||
| 94 | return normalized.split(/[\\/]/).findLast((segment) => segment.length > 0) ?? normalized; | ||
| 95 | } | ||
| 96 | |||
| 97 | function categoryLabel(category: ResourceTelemetryProcessCategory): string { | ||
| 98 | switch (category) { | ||
| 99 | case "server": | ||
| 100 | return "Server"; | ||
| 101 | case "server-child": | ||
| 102 | return "Backend child"; | ||
| 103 | case "provider-root": | ||
| 104 | return "Provider"; | ||
| 105 | case "terminal-root": | ||
| 106 | return "Terminal"; | ||
| 107 | case "electron-main": | ||
| 108 | return "Electron main"; | ||
| 109 | case "electron-renderer": | ||
| 110 | return "Renderer"; | ||
| 111 | case "electron-gpu": | ||
| 112 | return "GPU"; | ||
| 113 | case "electron-utility": | ||
| 114 | return "Electron utility"; | ||
| 115 | case "resource-monitor": | ||
| 116 | return "Monitor"; | ||
| 117 | case "unknown-t3": | ||
| 118 | return "T3 process"; | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | function categoryDotClass(category: ResourceTelemetryProcessCategory): string { | ||
| 123 | if (category === "resource-monitor") return "bg-amber-500"; | ||
| 124 | if (category.startsWith("electron-")) return "bg-sky-500"; | ||
| 125 | if (category === "server") return "bg-violet-500"; | ||
| 126 | return "bg-emerald-500"; | ||
| 127 | } | ||
| 128 | |||
| 129 | function ioSemanticsLabel(semantics: ResourceTelemetryIoSemantics): string { | ||
| 130 | switch (semantics) { | ||
| 131 | case "storage": | ||
| 132 | return "Storage bytes"; | ||
| 133 | case "logical": | ||
| 134 | return "Logical bytes"; | ||
| 135 | case "all-io": | ||
| 136 | return "All I/O bytes"; | ||
| 137 | case "unavailable": | ||
| 138 | return "Unavailable"; | ||
| 139 | } | ||
| 140 | } | ||
| 141 | |||
| 142 | function booleanStateLabel( | ||
| 143 | value: BackgroundBooleanState, | ||
| 144 | labels: { readonly true: string; readonly false: string }, | ||
| 145 | ): string { | ||
| 146 | if (value === "true") return labels.true; | ||
| 147 | if (value === "false") return labels.false; | ||
| 148 | return "Unknown"; | ||
| 149 | } | ||
| 150 | |||
| 151 | function sourceStatusTone(status: ResourceTelemetrySourceStatus): "default" | "warning" | "danger" { | ||
| 152 | if (status === "healthy") return "default"; | ||
| 153 | if (status === "starting" || status === "degraded") return "warning"; | ||
| 154 | return "danger"; | ||
| 155 | } | ||
| 156 | |||
| 157 | function SourceStatusBadge({ | ||
| 158 | label, | ||
| 159 | status, | ||
| 160 | }: { | ||
| 161 | label: string; | ||
| 162 | status: ResourceTelemetrySourceStatus; | ||
| 163 | }) { | ||
| 164 | const tone = sourceStatusTone(status); | ||
| 165 | return ( | ||
| 166 | <span | ||
| 167 | className={cn( | ||
| 168 | "inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]", | ||
| 169 | tone === "default" && | ||
| 170 | "border-emerald-500/20 bg-emerald-500/8 text-emerald-700 dark:text-emerald-300", | ||
| 171 | tone === "warning" && | ||
| 172 | "border-amber-500/25 bg-amber-500/8 text-amber-700 dark:text-amber-300", | ||
| 173 | tone === "danger" && "border-destructive/25 bg-destructive/8 text-destructive", | ||
| 174 | )} | ||
| 175 | > | ||
| 176 | <span | ||
| 177 | className={cn( | ||
| 178 | "size-1.5 rounded-full", | ||
| 179 | tone === "default" && "bg-emerald-500", | ||
| 180 | tone === "warning" && "bg-amber-500", | ||
| 181 | tone === "danger" && "bg-destructive", | ||
| 182 | )} | ||
| 183 | /> | ||
| 184 | {label} {status} | ||
| 185 | </span> | ||
| 186 | ); | ||
| 187 | } | ||
| 188 | |||
| 189 | function LastSampleLabel({ sampledAt }: { sampledAt: DateTime.Utc | null }) { | ||
| 190 | useRelativeTimeTick(); | ||
| 191 | if (!sampledAt) { | ||
| 192 | return <span className="text-[11px] text-muted-foreground/55">Waiting for sample</span>; | ||
| 193 | } | ||
| 194 | const relative = formatRelativeTime(DateTime.formatIso(sampledAt)); | ||
| 195 | return ( | ||
| 196 | <span className="text-[11px] text-muted-foreground/60"> | ||
| 197 | Updated <span className="font-mono tabular-nums">{relative.value}</span> | ||
| 198 | {relative.suffix ? ` ${relative.suffix}` : ""} | ||
| 199 | </span> | ||
| 200 | ); | ||
| 201 | } | ||
| 202 | |||
| 203 | function IconStat({ | ||
| 204 | icon, | ||
| 205 | label, | ||
| 206 | value, | ||
| 207 | detail, | ||
| 208 | tone = "default", | ||
| 209 | }: { | ||
| 210 | icon: ReactNode; | ||
| 211 | label: string; | ||
| 212 | value: string; | ||
| 213 | detail?: string | undefined; | ||
| 214 | tone?: "default" | "warning" | "danger"; | ||
| 215 | }) { | ||
| 216 | return ( | ||
| 217 | <div className="min-w-0 border-border/60 px-4 py-3.5 sm:px-5"> | ||
| 218 | <div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/65"> | ||
| 219 | <span className="text-muted-foreground/50">{icon}</span> | ||
| 220 | <span className="truncate">{label}</span> | ||
| 221 | </div> | ||
| 222 | <div | ||
| 223 | className={cn( | ||
| 224 | "mt-2 truncate font-mono text-xl font-semibold tracking-[-0.04em] tabular-nums text-foreground", | ||
| 225 | tone === "warning" && "text-amber-600 dark:text-amber-300", | ||
| 226 | tone === "danger" && "text-destructive", | ||
| 227 | )} | ||
| 228 | > | ||
| 229 | {value} | ||
| 230 | </div> | ||
| 231 | {detail ? ( | ||
| 232 | <div className="mt-1 truncate text-[10px] text-muted-foreground/55">{detail}</div> | ||
| 233 | ) : null} | ||
| 234 | </div> | ||
| 235 | ); | ||
| 236 | } | ||
| 237 | |||
| 238 | function AggregateCard({ | ||
| 239 | label, | ||
| 240 | accentClass, | ||
| 241 | aggregate, | ||
| 242 | }: { | ||
| 243 | label: string; | ||
| 244 | accentClass: string; | ||
| 245 | aggregate: ResourceTelemetryAggregate; | ||
| 246 | }) { | ||
| 247 | return ( | ||
| 248 | <div className="relative overflow-hidden border-t border-border/60 px-4 py-3.5 first:border-t-0 md:border-t-0 md:border-l md:first:border-l-0 sm:px-5"> | ||
| 249 | <span className={cn("absolute inset-x-0 top-0 h-px opacity-80", accentClass)} /> | ||
| 250 | <div className="flex items-center justify-between gap-3"> | ||
| 251 | <div className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/65"> | ||
| 252 | {label} | ||
| 253 | </div> | ||
| 254 | <div className="font-mono text-[10px] tabular-nums text-muted-foreground/55"> | ||
| 255 | {aggregate.processCount} proc | ||
| 256 | </div> | ||
| 257 | </div> | ||
| 258 | <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2"> | ||
| 259 | <MetricPair label="CPU" value={`${aggregate.currentCpuPercent.toFixed(1)}%`} /> | ||
| 260 | <MetricPair label="Memory" value={formatBytes(aggregate.currentRssBytes)} /> | ||
| 261 | <MetricPair label="Read" value={formatRate(aggregate.ioReadBytesPerSecond)} /> | ||
| 262 | <MetricPair label="Write" value={formatRate(aggregate.ioWriteBytesPerSecond)} /> | ||
| 263 | </div> | ||
| 264 | </div> | ||
| 265 | ); | ||
| 266 | } | ||
| 267 | |||
| 268 | function MetricPair({ label, value }: { label: string; value: string }) { | ||
| 269 | return ( | ||
| 270 | <div className="min-w-0"> | ||
| 271 | <div className="text-[9px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/45"> | ||
| 272 | {label} | ||
| 273 | </div> | ||
| 274 | <div className="truncate font-mono text-xs font-medium tabular-nums text-foreground/90"> | ||
| 275 | {value} | ||
| 276 | </div> | ||
| 277 | </div> | ||
| 278 | ); | ||
| 279 | } | ||
| 280 | |||
| 281 | function HealthSource({ label, health }: { label: string; health: ResourceTelemetrySourceHealth }) { | ||
| 282 | return ( | ||
| 283 | <div className="flex items-start justify-between gap-4 border-t border-border/50 py-2.5 first:border-t-0"> | ||
| 284 | <div className="min-w-0"> | ||
| 285 | <div className="text-xs font-medium text-foreground">{label}</div> | ||
| 286 | <div className="mt-0.5 truncate text-[10px] text-muted-foreground/60"> | ||
| 287 | {Option.match(health.lastError, { | ||
| 288 | onNone: () => "No reported errors", | ||
| 289 | onSome: (error) => error, | ||
| 290 | })} | ||
| 291 | </div> | ||
| 292 | </div> | ||
| 293 | <SourceStatusBadge label="" status={health.status} /> | ||
| 294 | </div> | ||
| 295 | ); | ||
| 296 | } | ||
| 297 | |||
| 298 | function DetailRow({ | ||
| 299 | label, | ||
| 300 | value, | ||
| 301 | valueClassName, | ||
| 302 | }: { | ||
| 303 | label: string; | ||
| 304 | value: ReactNode; | ||
| 305 | valueClassName?: string | undefined; | ||
| 306 | }) { | ||
| 307 | return ( | ||
| 308 | <div className="flex items-center justify-between gap-4 border-t border-border/50 py-2 first:border-t-0"> | ||
| 309 | <span className="text-[11px] text-muted-foreground/70">{label}</span> | ||
| 310 | <span | ||
| 311 | className={cn( | ||
| 312 | "min-w-0 truncate text-right font-mono text-[11px] tabular-nums text-foreground/85", | ||
| 313 | valueClassName, | ||
| 314 | )} | ||
| 315 | > | ||
| 316 | {value} | ||
| 317 | </span> | ||
| 318 | </div> | ||
| 319 | ); | ||
| 320 | } | ||
| 321 | |||
| 322 | function HistoryWindowSelector({ | ||
| 323 | selectedWindowMs, | ||
| 324 | onSelect, | ||
| 325 | }: { | ||
| 326 | selectedWindowMs: number; | ||
| 327 | onSelect: (windowMs: number) => void; | ||
| 328 | }) { | ||
| 329 | return ( | ||
| 330 | <div className="flex items-center rounded-md border border-border/60 p-0.5"> | ||
| 331 | {HISTORY_WINDOWS.map((option) => ( | ||
| 332 | <button | ||
| 333 | key={option.windowMs} | ||
| 334 | type="button" | ||
| 335 | className={cn( | ||
| 336 | "h-6 rounded-sm px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground", | ||
| 337 | selectedWindowMs === option.windowMs && "bg-muted text-foreground", | ||
| 338 | )} | ||
| 339 | onClick={() => onSelect(option.windowMs)} | ||
| 340 | > | ||
| 341 | {option.label} | ||
| 342 | </button> | ||
| 343 | ))} | ||
| 344 | </div> | ||
| 345 | ); | ||
| 346 | } | ||
| 347 | |||
| 348 | function ResourceHistoryChart({ | ||
| 349 | buckets, | ||
| 350 | }: { | ||
| 351 | buckets: ReadonlyArray<ResourceTelemetryHistoryBucket>; | ||
| 352 | }) { | ||
| 353 | const maxCpu = Math.max(1, ...buckets.map((bucket) => bucket.maxCpuPercent)); | ||
| 354 | const maxIo = Math.max(1, ...buckets.map((bucket) => bucket.ioReadBytes + bucket.ioWriteBytes)); | ||
| 355 | |||
| 356 | return ( | ||
| 357 | <div className="border-t border-border/60 px-4 py-4 sm:px-5"> | ||
| 358 | <div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-muted-foreground/65"> | ||
| 359 | <span className="inline-flex items-center gap-1.5"> | ||
| 360 | <span className="h-1.5 w-3 rounded-full bg-foreground/70" /> CPU average | ||
| 361 | </span> | ||
| 362 | <span className="inline-flex items-center gap-1.5"> | ||
| 363 | <span className="h-1.5 w-3 rounded-full bg-sky-500/70" /> I/O reads | ||
| 364 | </span> | ||
| 365 | <span className="inline-flex items-center gap-1.5"> | ||
| 366 | <span className="h-1.5 w-3 rounded-full bg-amber-500/80" /> I/O writes | ||
| 367 | </span> | ||
| 368 | </div> | ||
| 369 | <div className="flex h-32 items-end gap-1 overflow-hidden rounded-lg border border-border/40 bg-muted/8 px-2 pt-3 pb-2"> | ||
| 370 | {buckets.map((bucket) => { | ||
| 371 | const cpuHeight = Math.max(2, (bucket.avgCpuPercent / maxCpu) * 100); | ||
| 372 | const readHeight = Math.max(1, (bucket.ioReadBytes / maxIo) * 100); | ||
| 373 | const writeHeight = Math.max(1, (bucket.ioWriteBytes / maxIo) * 100); | ||
| 374 | return ( | ||
| 375 | <Tooltip key={DateTime.formatIso(bucket.startedAt)}> | ||
| 376 | <TooltipTrigger | ||
| 377 | render={ | ||
| 378 | <div className="grid h-full min-w-1 flex-1 grid-cols-3 items-end gap-px"> | ||
| 379 | <span | ||
| 380 | className="block min-h-px rounded-t-sm bg-foreground/65" | ||
| 381 | style={{ height: `${cpuHeight}%` }} | ||
| 382 | /> | ||
| 383 | <span | ||
| 384 | className="block min-h-px rounded-t-sm bg-sky-500/70" | ||
| 385 | style={{ height: `${readHeight}%` }} | ||
| 386 | /> | ||
| 387 | <span | ||
| 388 | className="block min-h-px rounded-t-sm bg-amber-500/80" | ||
| 389 | style={{ height: `${writeHeight}%` }} | ||
| 390 | /> | ||
| 391 | </div> | ||
| 392 | } | ||
| 393 | /> | ||
| 394 | <TooltipPopup side="top" className="space-y-0.5 text-left"> | ||
| 395 | <div>CPU avg {bucket.avgCpuPercent.toFixed(1)}%</div> | ||
| 396 | <div>CPU peak {bucket.maxCpuPercent.toFixed(1)}%</div> | ||
| 397 | <div>Read {formatBytes(bucket.ioReadBytes)}</div> | ||
| 398 | <div>Write {formatBytes(bucket.ioWriteBytes)}</div> | ||
| 399 | </TooltipPopup> | ||
| 400 | </Tooltip> | ||
| 401 | ); | ||
| 402 | })} | ||
| 403 | </div> | ||
| 404 | </div> | ||
| 405 | ); | ||
| 406 | } | ||
| 407 | |||
| 408 | function ProcessTreeName({ | ||
| 409 | process, | ||
| 410 | collapsed, | ||
| 411 | onToggle, | ||
| 412 | }: { | ||
| 413 | process: ResourceTelemetryProcess; | ||
| 414 | collapsed: boolean; | ||
| 415 | onToggle: (process: ResourceTelemetryProcess) => void; | ||
| 416 | }) { | ||
| 417 | const name = formatProcessName(process); | ||
| 418 | const hasChildren = process.childPids.length > 0; | ||
| 419 | const ChevronIcon = collapsed ? ChevronRightIcon : ChevronDownIcon; | ||
| 420 | return ( | ||
| 421 | <div | ||
| 422 | className="grid min-w-0 grid-cols-[1.25rem_0.375rem_minmax(0,1fr)] items-center gap-2" | ||
| 423 | style={{ paddingLeft: `${Math.min(process.depth, 7) * 10}px` }} | ||
| 424 | > | ||
| 425 | {hasChildren ? ( | ||
| 426 | <button | ||
| 427 | type="button" | ||
| 428 | className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" | ||
| 429 | onClick={() => onToggle(process)} | ||
| 430 | aria-label={collapsed ? `Expand ${name}` : `Collapse ${name}`} | ||
| 431 | > | ||
| 432 | <ChevronIcon className="size-3.5" /> | ||
| 433 | </button> | ||
| 434 | ) : ( | ||
| 435 | <span className="size-5" aria-hidden /> | ||
| 436 | )} | ||
| 437 | <span className={cn("size-1.5 rounded-full", categoryDotClass(process.category))} /> | ||
| 438 | <Tooltip> | ||
| 439 | <TooltipTrigger | ||
| 440 | render={<span className="min-w-0 truncate font-medium text-foreground">{name}</span>} | ||
| 441 | /> | ||
| 442 | <TooltipPopup | ||
| 443 | side="top" | ||
| 444 | className="max-w-[min(520px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px]" | ||
| 445 | > | ||
| 446 | {process.command || process.name} | ||
| 447 | </TooltipPopup> | ||
| 448 | </Tooltip> | ||
| 449 | </div> | ||
| 450 | ); | ||
| 451 | } | ||
| 452 | |||
| 453 | function canSignalProcess(process: ResourceTelemetryProcess): boolean { | ||
| 454 | return ( | ||
| 455 | process.category === "server-child" || | ||
| 456 | process.category === "provider-root" || | ||
| 457 | process.category === "terminal-root" | ||
| 458 | ); | ||
| 459 | } | ||
| 460 | |||
| 461 | function ProcessActions({ | ||
| 462 | process, | ||
| 463 | signalingKey, | ||
| 464 | onSignal, | ||
| 465 | }: { | ||
| 466 | process: ResourceTelemetryProcess; | ||
| 467 | signalingKey: string | null; | ||
| 468 | onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void; | ||
| 469 | }) { | ||
| 470 | if (!canSignalProcess(process)) { | ||
| 471 | return <span className="text-[10px] text-muted-foreground/35">—</span>; | ||
| 472 | } | ||
| 473 | const isSignaling = signalingKey === processIdentityKey(process); | ||
| 474 | return ( | ||
| 475 | <div className="flex items-center justify-end gap-1.5"> | ||
| 476 | <button | ||
| 477 | type="button" | ||
| 478 | disabled={isSignaling} | ||
| 479 | className="text-[10px] font-semibold text-muted-foreground hover:text-foreground disabled:opacity-50" | ||
| 480 | onClick={() => onSignal(process, "SIGINT")} | ||
| 481 | > | ||
| 482 | INT | ||
| 483 | </button> | ||
| 484 | <button | ||
| 485 | type="button" | ||
| 486 | disabled={isSignaling} | ||
| 487 | className="text-[10px] font-semibold text-destructive hover:underline disabled:opacity-50" | ||
| 488 | onClick={() => onSignal(process, "SIGKILL")} | ||
| 489 | > | ||
| 490 | KILL | ||
| 491 | </button> | ||
| 492 | </div> | ||
| 493 | ); | ||
| 494 | } | ||
| 495 | |||
| 496 | function ProcessTable({ | ||
| 497 | processes, | ||
| 498 | signalingKey, | ||
| 499 | onSignal, | ||
| 500 | }: { | ||
| 501 | processes: ReadonlyArray<ResourceTelemetryProcess>; | ||
| 502 | signalingKey: string | null; | ||
| 503 | onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void; | ||
| 504 | }) { | ||
| 505 | const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set()); | ||
| 506 | const visible = useMemo(() => { | ||
| 507 | const result: ResourceTelemetryProcess[] = []; | ||
| 508 | let hiddenDepth: number | null = null; | ||
| 509 | for (const process of processes) { | ||
| 510 | if (hiddenDepth !== null) { | ||
| 511 | if (process.depth > hiddenDepth) continue; | ||
| 512 | hiddenDepth = null; | ||
| 513 | } | ||
| 514 | result.push(process); | ||
| 515 | if (collapsed.has(processIdentityKey(process))) { | ||
| 516 | hiddenDepth = process.depth; | ||
| 517 | } | ||
| 518 | } | ||
| 519 | return result; | ||
| 520 | }, [collapsed, processes]); | ||
| 521 | const toggle = useCallback((process: ResourceTelemetryProcess) => { | ||
| 522 | const identityKey = processIdentityKey(process); | ||
| 523 | setCollapsed((current) => { | ||
| 524 | const next = new Set(current); | ||
| 525 | if (next.has(identityKey)) { | ||
| 526 | next.delete(identityKey); | ||
| 527 | } else { | ||
| 528 | next.add(identityKey); | ||
| 529 | } | ||
| 530 | return next; | ||
| 531 | }); | ||
| 532 | }, []); | ||
| 533 | |||
| 534 | return ( | ||
| 535 | <ScrollArea | ||
| 536 | chainVerticalScroll | ||
| 537 | scrollFade | ||
| 538 | hideScrollbars | ||
| 539 | className="max-h-[min(68vh,48rem)] w-full max-w-full border-t border-border/60" | ||
| 540 | > | ||
| 541 | <table className="w-full min-w-[1320px] table-fixed text-left text-xs"> | ||
| 542 | <colgroup> | ||
| 543 | <col className="w-[20%]" /> | ||
| 544 | <col className="w-[10%]" /> | ||
| 545 | <col className="w-[7%]" /> | ||
| 546 | <col className="w-[8%]" /> | ||
| 547 | <col className="w-[9%]" /> | ||
| 548 | <col className="w-[9%]" /> | ||
| 549 | <col className="w-[9%]" /> | ||
| 550 | <col className="w-[10%]" /> | ||
| 551 | <col className="w-[8%]" /> | ||
| 552 | <col className="w-[6%]" /> | ||
| 553 | <col className="w-[4%]" /> | ||
| 554 | </colgroup> | ||
| 555 | <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-[10px] uppercase tracking-[0.08em] text-muted-foreground/65"> | ||
| 556 | <tr> | ||
| 557 | <th className="px-4 py-2 font-semibold sm:pl-5">Process</th> | ||
| 558 | <th className="px-3 py-2 font-semibold">Category</th> | ||
| 559 | <th className="px-3 py-2 text-right font-semibold">CPU</th> | ||
| 560 | <th className="px-3 py-2 text-right font-semibold">CPU Time</th> | ||
| 561 | <th className="px-3 py-2 text-right font-semibold">Memory</th> | ||
| 562 | <th className="px-3 py-2 text-right font-semibold">Read/s</th> | ||
| 563 | <th className="px-3 py-2 text-right font-semibold">Write/s</th> | ||
| 564 | <th className="px-3 py-2 text-right font-semibold">Read Total</th> | ||
| 565 | <th className="px-3 py-2 text-right font-semibold">Write Total</th> | ||
| 566 | <th className="px-3 py-2 text-right font-semibold">PID</th> | ||
| 567 | <th className="px-2 py-2 text-right font-semibold sm:pr-4">Kill</th> | ||
| 568 | </tr> | ||
| 569 | </thead> | ||
| 570 | <tbody className="divide-y divide-border/50"> | ||
| 571 | {visible.length === 0 ? ( | ||
| 572 | <tr> | ||
| 573 | <td colSpan={11} className="px-4 py-5 text-xs text-muted-foreground sm:px-5"> | ||
| 574 | Waiting for the native process monitor. | ||
| 575 | </td> | ||
| 576 | </tr> | ||
| 577 | ) : null} | ||
| 578 | {visible.map((process) => ( | ||
| 579 | <tr key={processIdentityKey(process)} className="hover:bg-muted/20"> | ||
| 580 | <td className="px-4 py-2 sm:pl-5"> | ||
| 581 | <ProcessTreeName | ||
| 582 | process={process} | ||
| 583 | collapsed={collapsed.has(processIdentityKey(process))} | ||
| 584 | onToggle={toggle} | ||
| 585 | /> | ||
| 586 | </td> | ||
| 587 | <td className="truncate px-3 py-2 text-[11px] text-muted-foreground"> | ||
| 588 | {categoryLabel(process.category)} | ||
| 589 | </td> | ||
| 590 | <td className="px-3 py-2 text-right font-mono tabular-nums"> | ||
| 591 | {process.cpuPercent.toFixed(1)}% | ||
| 592 | </td> | ||
| 593 | <td className="px-3 py-2 text-right font-mono tabular-nums"> | ||
| 594 | {formatCpuTime(process.cpuTimeMs)} | ||
| 595 | </td> | ||
| 596 | <td className="px-3 py-2 text-right font-mono tabular-nums"> | ||
| 597 | {formatBytes(process.residentBytes)} | ||
| 598 | </td> | ||
| 599 | <td className="px-3 py-2 text-right font-mono tabular-nums text-sky-700 dark:text-sky-300"> | ||
| 600 | {formatRate(process.ioReadBytesPerSecond)} | ||
| 601 | </td> | ||
| 602 | <td className="px-3 py-2 text-right font-mono tabular-nums text-amber-700 dark:text-amber-300"> | ||
| 603 | {formatRate(process.ioWriteBytesPerSecond)} | ||
| 604 | </td> | ||
| 605 | <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground"> | ||
| 606 | {formatBytes(process.ioReadBytes)} | ||
| 607 | </td> | ||
| 608 | <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground"> | ||
| 609 | <Tooltip> | ||
| 610 | <TooltipTrigger render={<span>{formatBytes(process.ioWriteBytes)}</span>} /> | ||
| 611 | <TooltipPopup side="top">{ioSemanticsLabel(process.ioSemantics)}</TooltipPopup> | ||
| 612 | </Tooltip> | ||
| 613 | </td> | ||
| 614 | <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground"> | ||
| 615 | {process.identity.pid} | ||
| 616 | </td> | ||
| 617 | <td className="px-2 py-2 text-right sm:pr-4"> | ||
| 618 | <ProcessActions process={process} signalingKey={signalingKey} onSignal={onSignal} /> | ||
| 619 | </td> | ||
| 620 | </tr> | ||
| 621 | ))} | ||
| 622 | </tbody> | ||
| 623 | </table> | ||
| 624 | </ScrollArea> | ||
| 625 | ); | ||
| 626 | } | ||
| 627 | |||
| 628 | function HistoryProcessTable({ | ||
| 629 | processes, | ||
| 630 | }: { | ||
| 631 | processes: ReadonlyArray<ResourceTelemetryProcessSummary>; | ||
| 632 | }) { | ||
| 633 | return ( | ||
| 634 | <ScrollArea | ||
| 635 | chainVerticalScroll | ||
| 636 | scrollFade | ||
| 637 | hideScrollbars | ||
| 638 | className="max-h-[28rem] w-full max-w-full border-t border-border/60" | ||
| 639 | > | ||
| 640 | <table className="w-full min-w-[1020px] table-fixed text-left text-xs"> | ||
| 641 | <colgroup> | ||
| 642 | <col className="w-[24%]" /> | ||
| 643 | <col className="w-[11%]" /> | ||
| 644 | <col className="w-[10%]" /> | ||
| 645 | <col className="w-[10%]" /> | ||
| 646 | <col className="w-[11%]" /> | ||
| 647 | <col className="w-[11%]" /> | ||
| 648 | <col className="w-[11%]" /> | ||
| 649 | <col className="w-[7%]" /> | ||
| 650 | <col className="w-[5%]" /> | ||
| 651 | </colgroup> | ||
| 652 | <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-[10px] uppercase tracking-[0.08em] text-muted-foreground/65"> | ||
| 653 | <tr> | ||
| 654 | <th className="px-4 py-2 font-semibold sm:pl-5">Process</th> | ||
| 655 | <th className="px-3 py-2 font-semibold">Category</th> | ||
| 656 | <th className="px-3 py-2 text-right font-semibold">CPU Time</th> | ||
| 657 | <th className="px-3 py-2 text-right font-semibold">Peak CPU</th> | ||
| 658 | <th className="px-3 py-2 text-right font-semibold">Peak Mem</th> | ||
| 659 | <th className="px-3 py-2 text-right font-semibold">Read</th> | ||
| 660 | <th className="px-3 py-2 text-right font-semibold">Write</th> | ||
| 661 | <th className="px-3 py-2 text-right font-semibold">Samples</th> | ||
| 662 | <th className="px-3 py-2 text-right font-semibold sm:pr-5">PID</th> | ||
| 663 | </tr> | ||
| 664 | </thead> | ||
| 665 | <tbody className="divide-y divide-border/50"> | ||
| 666 | {processes.length === 0 ? ( | ||
| 667 | <tr> | ||
| 668 | <td colSpan={9} className="px-4 py-5 text-xs text-muted-foreground sm:px-5"> | ||
| 669 | No retained process samples in this window. | ||
| 670 | </td> | ||
| 671 | </tr> | ||
| 672 | ) : null} | ||
| 673 | {processes.map((process) => ( | ||
| 674 | <tr key={processSummaryIdentityKey(process)} className="hover:bg-muted/20"> | ||
| 675 | <td className="px-4 py-2 sm:pl-5"> | ||
| 676 | <Tooltip> | ||
| 677 | <TooltipTrigger | ||
| 678 | render={ | ||
| 679 | <span className="block truncate font-medium text-foreground"> | ||
| 680 | {process.name || process.command} | ||
| 681 | </span> | ||
| 682 | } | ||
| 683 | /> | ||
| 684 | <TooltipPopup | ||
| 685 | side="top" | ||
| 686 | className="max-w-[min(520px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px]" | ||
| 687 | > | ||
| 688 | {process.command || process.name} | ||
| 689 | </TooltipPopup> | ||
| 690 | </Tooltip> | ||
| 691 | </td> | ||
| 692 | <td className="truncate px-3 py-2 text-[11px] text-muted-foreground"> | ||
| 693 | {categoryLabel(process.category)} | ||
| 694 | </td> | ||
| 695 | <td className="px-3 py-2 text-right font-mono tabular-nums"> | ||
| 696 | {formatCpuTime(process.cpuTimeMs)} | ||
| 697 | </td> | ||
| 698 | <td className="px-3 py-2 text-right font-mono tabular-nums"> | ||
| 699 | {process.maxCpuPercent.toFixed(1)}% | ||
| 700 | </td> | ||
| 701 | <td className="px-3 py-2 text-right font-mono tabular-nums"> | ||
| 702 | {formatBytes(process.peakRssBytes)} | ||
| 703 | </td> | ||
| 704 | <td className="px-3 py-2 text-right font-mono tabular-nums text-sky-700 dark:text-sky-300"> | ||
| 705 | {formatBytes(process.ioReadBytes)} | ||
| 706 | </td> | ||
| 707 | <td className="px-3 py-2 text-right font-mono tabular-nums text-amber-700 dark:text-amber-300"> | ||
| 708 | {formatBytes(process.ioWriteBytes)} | ||
| 709 | </td> | ||
| 710 | <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground"> | ||
| 711 | {process.sampleCount} | ||
| 712 | </td> | ||
| 713 | <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground sm:pr-5"> | ||
| 714 | {process.identity.pid} | ||
| 715 | </td> | ||
| 716 | </tr> | ||
| 717 | ))} | ||
| 718 | </tbody> | ||
| 719 | </table> | ||
| 720 | </ScrollArea> | ||
| 721 | ); | ||
| 722 | } | ||
| 723 | |||
| 724 | function AttributionTable({ entries }: { entries: ReadonlyArray<ResourceAttributionEntry> }) { | ||
| 725 | return ( | ||
| 726 | <div className="overflow-x-auto border-t border-border/60"> | ||
| 727 | <table className="w-full min-w-[720px] table-fixed text-left text-xs"> | ||
| 728 | <colgroup> | ||
| 729 | <col className="w-[22%]" /> | ||
| 730 | <col className="w-[28%]" /> | ||
| 731 | <col className="w-[14%]" /> | ||
| 732 | <col className="w-[14%]" /> | ||
| 733 | <col className="w-[10%]" /> | ||
| 734 | <col className="w-[12%]" /> | ||
| 735 | </colgroup> | ||
| 736 | <thead className="border-b border-border/60 text-[10px] uppercase tracking-[0.08em] text-muted-foreground/65"> | ||
| 737 | <tr> | ||
| 738 | <th className="px-4 py-2 font-semibold sm:pl-5">Component</th> | ||
| 739 | <th className="px-3 py-2 font-semibold">Operation</th> | ||
| 740 | <th className="px-3 py-2 text-right font-semibold">Logical Read</th> | ||
| 741 | <th className="px-3 py-2 text-right font-semibold">Logical Write</th> | ||
| 742 | <th className="px-3 py-2 text-right font-semibold">Count</th> | ||
| 743 | <th className="px-3 py-2 text-right font-semibold sm:pr-5">Time</th> | ||
| 744 | </tr> | ||
| 745 | </thead> | ||
| 746 | <tbody className="divide-y divide-border/50"> | ||
| 747 | {entries.length === 0 ? ( | ||
| 748 | <tr> | ||
| 749 | <td colSpan={6} className="px-4 py-5 text-xs text-muted-foreground sm:px-5"> | ||
| 750 | No instrumented application I/O has been recorded yet. | ||
| 751 | </td> | ||
| 752 | </tr> | ||
| 753 | ) : null} | ||
| 754 | {entries.map((entry) => ( | ||
| 755 | <tr key={`${entry.component}:${entry.operation}`} className="hover:bg-muted/20"> | ||
| 756 | <td className="truncate px-4 py-2 font-medium text-foreground sm:pl-5"> | ||
| 757 | {entry.component} | ||
| 758 | </td> | ||
| 759 | <td className="truncate px-3 py-2 text-muted-foreground">{entry.operation}</td> | ||
| 760 | <td className="px-3 py-2 text-right font-mono tabular-nums text-sky-700 dark:text-sky-300"> | ||
| 761 | {formatBytes(entry.logicalReadBytes)} | ||
| 762 | </td> | ||
| 763 | <td className="px-3 py-2 text-right font-mono tabular-nums text-amber-700 dark:text-amber-300"> | ||
| 764 | {formatBytes(entry.logicalWriteBytes)} | ||
| 765 | </td> | ||
| 766 | <td className="px-3 py-2 text-right font-mono tabular-nums">{entry.count}</td> | ||
| 767 | <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground sm:pr-5"> | ||
| 768 | {(entry.durationMs / 1_000).toFixed(2)}s | ||
| 769 | </td> | ||
| 770 | </tr> | ||
| 771 | ))} | ||
| 772 | </tbody> | ||
| 773 | </table> | ||
| 774 | </div> | ||
| 775 | ); | ||
| 776 | } | ||
| 777 | |||
| 778 | export function ResourceTelemetryDiagnostics() { | ||
| 779 | const [windowMs, setWindowMs] = useState(15 * 60_000); | ||
| 780 | const selectedWindow = | ||
| 781 | HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1]; | ||
| 782 | const telemetry = useResourceTelemetry(); | ||
| 783 | const retryTelemetry = telemetry.retry; | ||
| 784 | const history = useResourceTelemetryHistory({ | ||
| 785 | windowMs: selectedWindow.windowMs, | ||
| 786 | bucketMs: selectedWindow.bucketMs, | ||
| 787 | }); | ||
| 788 | const [signalingKey, setSignalingKey] = useState<string | null>(null); | ||
| 789 | const [isRetrying, setIsRetrying] = useState(false); | ||
| 790 | const snapshot = telemetry.data; | ||
| 791 | const allT3 = snapshot?.groups.allT3; | ||
| 792 | |||
| 793 | const signalProcess = useCallback( | ||
| 794 | (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => { | ||
| 795 | if ( | ||
| 796 | signal === "SIGKILL" && | ||
| 797 | !window.confirm( | ||
| 798 | `Send SIGKILL to process ${process.identity.pid}? This cannot be handled by the process.`, | ||
| 799 | ) | ||
| 800 | ) { | ||
| 801 | return; | ||
| 802 | } | ||
| 803 | const identityKey = processIdentityKey(process); | ||
| 804 | setSignalingKey(identityKey); | ||
| 805 | void ensureLocalApi() | ||
| 806 | .server.signalProcess({ | ||
| 807 | pid: process.identity.pid, | ||
| 808 | startTimeMs: process.identity.startTimeMs, | ||
| 809 | signal, | ||
| 810 | }) | ||
| 811 | .then((result) => { | ||
| 812 | if (result.signaled) return; | ||
| 813 | toastManager.add({ | ||
| 814 | type: "error", | ||
| 815 | title: `Could not send ${signal}`, | ||
| 816 | description: Option.getOrElse( | ||
| 817 | result.message, | ||
| 818 | () => `Failed to send ${signal} to process ${process.identity.pid}.`, | ||
| 819 | ), | ||
| 820 | }); | ||
| 821 | }) | ||
| 822 | .catch((error: unknown) => { | ||
| 823 | toastManager.add({ | ||
| 824 | type: "error", | ||
| 825 | title: `Could not send ${signal}`, | ||
| 826 | description: error instanceof Error ? error.message : `Failed to send ${signal}.`, | ||
| 827 | }); | ||
| 828 | }) | ||
| 829 | .finally(() => { | ||
| 830 | setSignalingKey((current) => (current === identityKey ? null : current)); | ||
| 831 | }); | ||
| 832 | }, | ||
| 833 | [], | ||
| 834 | ); | ||
| 835 | |||
| 836 | const retryCollector = useCallback(() => { | ||
| 837 | setIsRetrying(true); | ||
| 838 | void retryTelemetry() | ||
| 839 | .catch((error: unknown) => { | ||
| 840 | toastManager.add({ | ||
| 841 | type: "error", | ||
| 842 | title: "Could not restart resource monitor", | ||
| 843 | description: | ||
| 844 | error instanceof Error ? error.message : "The resource monitor retry failed.", | ||
| 845 | }); | ||
| 846 | }) | ||
| 847 | .finally(() => { | ||
| 848 | setIsRetrying(false); | ||
| 849 | }); | ||
| 850 | }, [retryTelemetry]); | ||
| 851 | |||
| 852 | const speedLimit = snapshot ? Option.getOrNull(snapshot.speedLimitPercent) : null; | ||
| 853 | const collectorNeedsRetry = | ||
| 854 | snapshot?.health.native.status === "degraded" || | ||
| 855 | snapshot?.health.native.status === "unavailable" || | ||
| 856 | snapshot?.health.native.status === "stopped"; | ||
| 857 | |||
| 858 | return ( | ||
| 859 | <> | ||
| 860 | <SettingsSection | ||
| 861 | title="Resource Overview" | ||
| 862 | headerAction={ | ||
| 863 | <div className="flex items-center gap-2"> | ||
| 864 | {snapshot ? ( | ||
| 865 | <SourceStatusBadge label="Native" status={snapshot.health.native.status} /> | ||
| 866 | ) : null} | ||
| 867 | <LastSampleLabel sampledAt={snapshot?.readAt ?? null} /> | ||
| 868 | <Tooltip> | ||
| 869 | <TooltipTrigger | ||
| 870 | render={ | ||
| 871 | <Button | ||
| 872 | size="icon-xs" | ||
| 873 | variant="ghost" | ||
| 874 | className="size-5 rounded-sm p-0" | ||
| 875 | disabled={telemetry.isPending} | ||
| 876 | onClick={telemetry.refresh} | ||
| 877 | aria-label="Refresh resource telemetry" | ||
| 878 | > | ||
| 879 | <RefreshCwIcon | ||
| 880 | className={cn("size-3", telemetry.isPending && "animate-spin")} | ||
| 881 | /> | ||
| 882 | </Button> | ||
| 883 | } | ||
| 884 | /> | ||
| 885 | <TooltipPopup side="top">Refresh telemetry snapshot</TooltipPopup> | ||
| 886 | </Tooltip> | ||
| 887 | </div> | ||
| 888 | } | ||
| 889 | > | ||
| 890 | <div className="grid grid-cols-2 divide-x divide-y divide-border/60 md:grid-cols-3"> | ||
| 891 | <IconStat | ||
| 892 | icon={<CpuIcon className="size-3.5" />} | ||
| 893 | label="Current CPU" | ||
| 894 | value={allT3 ? `${allT3.currentCpuPercent.toFixed(1)}%` : "..."} | ||
| 895 | detail={allT3 ? `${formatCpuTime(allT3.cpuTimeMs)} observed CPU time` : undefined} | ||
| 896 | /> | ||
| 897 | <IconStat | ||
| 898 | icon={<MemoryStickIcon className="size-3.5" />} | ||
| 899 | label="Resident Memory" | ||
| 900 | value={allT3 ? formatBytes(allT3.currentRssBytes) : "..."} | ||
| 901 | detail={allT3 ? `${formatBytes(allT3.peakRssBytes)} process peaks` : undefined} | ||
| 902 | /> | ||
| 903 | <IconStat | ||
| 904 | icon={<HardDriveIcon className="size-3.5" />} | ||
| 905 | label="I/O Reads" | ||
| 906 | value={allT3 ? formatRate(allT3.ioReadBytesPerSecond) : "..."} | ||
| 907 | detail={allT3 ? `${formatBytes(allT3.ioReadBytes)} observed` : undefined} | ||
| 908 | /> | ||
| 909 | <IconStat | ||
| 910 | icon={<DatabaseIcon className="size-3.5" />} | ||
| 911 | label="I/O Writes" | ||
| 912 | value={allT3 ? formatRate(allT3.ioWriteBytesPerSecond) : "..."} | ||
| 913 | detail={allT3 ? `${formatBytes(allT3.ioWriteBytes)} observed` : undefined} | ||
| 914 | tone={ | ||
| 915 | allT3 && allT3.ioWriteBytesPerSecond >= 10 * 1_024 * 1_024 | ||
| 916 | ? "danger" | ||
| 917 | : allT3 && allT3.ioWriteBytesPerSecond >= 1_024 * 1_024 | ||
| 918 | ? "warning" | ||
| 919 | : "default" | ||
| 920 | } | ||
| 921 | /> | ||
| 922 | <IconStat | ||
| 923 | icon={<ActivityIcon className="size-3.5" />} | ||
| 924 | label="Processes" | ||
| 925 | value={allT3 ? String(allT3.processCount) : "..."} | ||
| 926 | detail={ | ||
| 927 | allT3 ? `${allT3.processStarts} starts · ${allT3.processExits} exits` : undefined | ||
| 928 | } | ||
| 929 | /> | ||
| 930 | <IconStat | ||
| 931 | icon={<GaugeIcon className="size-3.5" />} | ||
| 932 | label="CPU Speed Limit" | ||
| 933 | value={ | ||
| 934 | snapshot ? (speedLimit === null ? "Unknown" : `${speedLimit.toFixed(0)}%`) : "..." | ||
| 935 | } | ||
| 936 | detail={snapshot ? `${snapshot.power.thermalState} thermal state` : undefined} | ||
| 937 | tone={speedLimit !== null && speedLimit < 80 ? "warning" : "default"} | ||
| 938 | /> | ||
| 939 | </div> | ||
| 940 | {telemetry.error ? ( | ||
| 941 | <div className="flex items-start gap-2 border-t border-border/60 px-4 py-3 text-xs text-destructive sm:px-5"> | ||
| 942 | <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" /> | ||
| 943 | <span>{telemetry.error}</span> | ||
| 944 | </div> | ||
| 945 | ) : null} | ||
| 946 | {snapshot ? ( | ||
| 947 | <div className="grid md:grid-cols-3"> | ||
| 948 | <AggregateCard | ||
| 949 | label="Backend + Agents" | ||
| 950 | accentClass="bg-emerald-500/80" | ||
| 951 | aggregate={snapshot.groups.backend} | ||
| 952 | /> | ||
| 953 | <AggregateCard | ||
| 954 | label="Electron" | ||
| 955 | accentClass="bg-sky-500/80" | ||
| 956 | aggregate={snapshot.groups.electron} | ||
| 957 | /> | ||
| 958 | <AggregateCard | ||
| 959 | label="Monitor Overhead" | ||
| 960 | accentClass="bg-amber-500/80" | ||
| 961 | aggregate={snapshot.groups.monitor} | ||
| 962 | /> | ||
| 963 | </div> | ||
| 964 | ) : null} | ||
| 965 | </SettingsSection> | ||
| 966 | |||
| 967 | <SettingsSection | ||
| 968 | title="Power & Collector" | ||
| 969 | headerAction={ | ||
| 970 | collectorNeedsRetry ? ( | ||
| 971 | <Button size="xs" variant="outline" disabled={isRetrying} onClick={retryCollector}> | ||
| 972 | <RotateCcwIcon className={cn("size-3", isRetrying && "animate-spin")} /> | ||
| 973 | Retry monitor | ||
| 974 | </Button> | ||
| 975 | ) : null | ||
| 976 | } | ||
| 977 | > | ||
| 978 | <div className="grid md:grid-cols-2 md:divide-x md:divide-border/60"> | ||
| 979 | <div className="px-4 py-3.5 sm:px-5"> | ||
| 980 | <div className="mb-2 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/60"> | ||
| 981 | <BatteryIcon className="size-3.5" /> Host state | ||
| 982 | </div> | ||
| 983 | <DetailRow | ||
| 984 | label="Power source" | ||
| 985 | value={ | ||
| 986 | snapshot | ||
| 987 | ? booleanStateLabel(snapshot.power.onBattery, { | ||
| 988 | true: "Battery", | ||
| 989 | false: "External power", | ||
| 990 | }) | ||
| 991 | : "Unknown" | ||
| 992 | } | ||
| 993 | /> | ||
| 994 | <DetailRow | ||
| 995 | label="Low power mode" | ||
| 996 | value={ | ||
| 997 | snapshot | ||
| 998 | ? booleanStateLabel(snapshot.power.lowPowerMode, { | ||
| 999 | true: "Enabled", | ||
| 1000 | false: "Disabled", | ||
| 1001 | }) | ||
| 1002 | : "Unknown" | ||
| 1003 | } | ||
| 1004 | /> | ||
| 1005 | <DetailRow | ||
| 1006 | label="Idle" | ||
| 1007 | value={ | ||
| 1008 | snapshot | ||
| 1009 | ? `${booleanStateLabel(snapshot.power.idle, { | ||
| 1010 | true: "Idle", | ||
| 1011 | false: "Active", | ||
| 1012 | })}${ | ||
| 1013 | snapshot.power.idleSeconds === null | ||
| 1014 | ? "" | ||
| 1015 | : ` · ${Math.round(snapshot.power.idleSeconds)}s` | ||
| 1016 | }` | ||
| 1017 | : "Unknown" | ||
| 1018 | } | ||
| 1019 | /> | ||
| 1020 | <DetailRow | ||
| 1021 | label="Session" | ||
| 1022 | value={ | ||
| 1023 | snapshot | ||
| 1024 | ? snapshot.power.suspended | ||
| 1025 | ? "Suspended" | ||
| 1026 | : booleanStateLabel(snapshot.power.locked, { | ||
| 1027 | true: "Locked", | ||
| 1028 | false: "Unlocked", | ||
| 1029 | }) | ||
| 1030 | : "Unknown" | ||
| 1031 | } | ||
| 1032 | /> | ||
| 1033 | <DetailRow | ||
| 1034 | label="Thermal" | ||
| 1035 | value={snapshot?.power.thermalState ?? "unknown"} | ||
| 1036 | valueClassName={ | ||
| 1037 | snapshot?.power.thermalState === "serious" || | ||
| 1038 | snapshot?.power.thermalState === "critical" | ||
| 1039 | ? "text-destructive" | ||
| 1040 | : undefined | ||
| 1041 | } | ||
| 1042 | /> | ||
| 1043 | </div> | ||
| 1044 | <div className="border-t border-border/60 px-4 py-3.5 md:border-t-0 sm:px-5"> | ||
| 1045 | <div className="mb-2 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/60"> | ||
| 1046 | <GaugeIcon className="size-3.5" /> Collection health | ||
| 1047 | </div> | ||
| 1048 | {snapshot ? ( | ||
| 1049 | <> | ||
| 1050 | <HealthSource label="Native process monitor" health={snapshot.health.native} /> | ||
| 1051 | <HealthSource label="Electron main process" health={snapshot.health.desktop} /> | ||
| 1052 | <DetailRow | ||
| 1053 | label="Collection time" | ||
| 1054 | value={formatDurationMicros(snapshot.health.collectionDurationMicros)} | ||
| 1055 | /> | ||
| 1056 | <DetailRow | ||
| 1057 | label="Process scan" | ||
| 1058 | value={`${snapshot.health.retainedProcessCount}/${snapshot.health.scannedProcessCount} retained`} | ||
| 1059 | /> | ||
| 1060 | <DetailRow | ||
| 1061 | label="Inaccessible" | ||
| 1062 | value={String(snapshot.health.inaccessibleProcessCount)} | ||
| 1063 | valueClassName={ | ||
| 1064 | snapshot.health.inaccessibleProcessCount > 0 | ||
| 1065 | ? "text-amber-600 dark:text-amber-300" | ||
| 1066 | : undefined | ||
| 1067 | } | ||
| 1068 | /> | ||
| 1069 | <DetailRow | ||
| 1070 | label="Sidecar" | ||
| 1071 | value={Option.match(snapshot.health.sidecarVersion, { | ||
| 1072 | onNone: () => "Unavailable", | ||
| 1073 | onSome: (version) => | ||
| 1074 | `${version}${Option.match(snapshot.health.sidecarPid, { | ||
| 1075 | onNone: () => "", | ||
| 1076 | onSome: (pid) => ` · PID ${pid}`, | ||
| 1077 | })}`, | ||
| 1078 | })} | ||
| 1079 | /> | ||
| 1080 | <DetailRow label="Restarts" value={String(snapshot.health.restartCount)} /> | ||
| 1081 | </> | ||
| 1082 | ) : ( | ||
| 1083 | <div className="py-4 text-xs text-muted-foreground"> | ||
| 1084 | Waiting for collector health. | ||
| 1085 | </div> | ||
| 1086 | )} | ||
| 1087 | </div> | ||
| 1088 | </div> | ||
| 1089 | </SettingsSection> | ||
| 1090 | |||
| 1091 | <SettingsSection | ||
| 1092 | title="Resource Timeline" | ||
| 1093 | headerAction={ | ||
| 1094 | <div className="flex items-center gap-2"> | ||
| 1095 | <HistoryWindowSelector selectedWindowMs={windowMs} onSelect={setWindowMs} /> | ||
| 1096 | <Button | ||
| 1097 | size="icon-xs" | ||
| 1098 | variant="ghost" | ||
| 1099 | className="size-5 rounded-sm p-0" | ||
| 1100 | disabled={history.isPending} | ||
| 1101 | onClick={history.refresh} | ||
| 1102 | aria-label="Refresh resource history" | ||
| 1103 | > | ||
| 1104 | <RefreshCwIcon className={cn("size-3", history.isPending && "animate-spin")} /> | ||
| 1105 | </Button> | ||
| 1106 | </div> | ||
| 1107 | } | ||
| 1108 | > | ||
| 1109 | {history.error ? ( | ||
| 1110 | <div className="flex items-start gap-2 px-4 py-3 text-xs text-destructive sm:px-5"> | ||
| 1111 | <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" /> | ||
| 1112 | <span>{history.error}</span> | ||
| 1113 | </div> | ||
| 1114 | ) : null} | ||
| 1115 | <ResourceHistoryChart buckets={history.data?.buckets ?? []} /> | ||
| 1116 | <HistoryProcessTable processes={history.data?.topProcesses ?? []} /> | ||
| 1117 | </SettingsSection> | ||
| 1118 | |||
| 1119 | <SettingsSection | ||
| 1120 | title="Live Process Tree" | ||
| 1121 | headerAction={ | ||
| 1122 | snapshot ? ( | ||
| 1123 | <span className="text-[10px] text-muted-foreground/55"> | ||
| 1124 | Identity: <span className="font-mono">PID + start time</span> | ||
| 1125 | </span> | ||
| 1126 | ) : null | ||
| 1127 | } | ||
| 1128 | > | ||
| 1129 | <ProcessTable | ||
| 1130 | processes={snapshot?.processes ?? []} | ||
| 1131 | signalingKey={signalingKey} | ||
| 1132 | onSignal={signalProcess} | ||
| 1133 | /> | ||
| 1134 | </SettingsSection> | ||
| 1135 | |||
| 1136 | <SettingsSection | ||
| 1137 | title="Instrumented Application I/O" | ||
| 1138 | headerAction={ | ||
| 1139 | <span className="text-[10px] text-muted-foreground/55">Logical bytes by operation</span> | ||
| 1140 | } | ||
| 1141 | > | ||
| 1142 | <div className="px-4 py-3 text-[11px] leading-relaxed text-muted-foreground sm:px-5"> | ||
| 1143 | Native counters identify which process is reading or writing. These application-level | ||
| 1144 | counters identify known T3 operations so process spikes can be correlated with specific | ||
| 1145 | persistence and logging paths. | ||
| 1146 | </div> | ||
| 1147 | <AttributionTable entries={snapshot?.attribution.entries ?? []} /> | ||
| 1148 | </SettingsSection> | ||
| 1149 | </> | ||
| 1150 | ); | ||
| 1151 | } | ||
| @@ -172,11 +172,15 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { · 171 unmodified lines | |||
| 172 | getTraceDiagnostics: vi.fn(), | 172 | getTraceDiagnostics: vi.fn(), |
| 173 | getProcessDiagnostics: vi.fn(), | 173 | getProcessDiagnostics: vi.fn(), |
| 174 | getProcessResourceHistory: vi.fn(), | 174 | getProcessResourceHistory: vi.fn(), |
| 175 | getResourceTelemetry: vi.fn(), | ||
| 176 | getResourceTelemetryHistory: vi.fn(), | ||
| 177 | retryResourceTelemetry: vi.fn(), | ||
| 175 | signalProcess: vi.fn(), | 178 | signalProcess: vi.fn(), |
| 176 | reportClientActivity: vi.fn(), | 179 | reportClientActivity: vi.fn(), |
| 177 | reportHostPowerState: vi.fn(), | 180 | reportHostPowerState: vi.fn(), |
| 178 | getBackgroundPolicy: vi.fn(), | 181 | getBackgroundPolicy: vi.fn(), |
| 179 | subscribeBackgroundPolicy: vi.fn(() => () => undefined), | 182 | subscribeBackgroundPolicy: vi.fn(() => () => undefined), |
| 183 | subscribeResourceTelemetry: vi.fn(() => () => undefined), | ||
| 180 | }, | 184 | }, |
| 181 | }; | 185 | }; |
| 182 | return { | 186 | return { |
| @@ -0,0 +1,228 @@ | |||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { | ||
| 3 | ResourceTelemetryHistory, | ||
| 4 | ResourceTelemetryHistoryInput, | ||
| 5 | ResourceTelemetrySnapshot, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as Cause from "effect/Cause"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 11 | import { useCallback, useEffect } from "react"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | getPrimaryEnvironmentConnection, | ||
| 15 | subscribeEnvironmentConnections, | ||
| 16 | } from "../environments/runtime"; | ||
| 17 | import { ensureLocalApi } from "../localApi"; | ||
| 18 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 19 | |||
| 20 | const RESOURCE_TELEMETRY_HISTORY_STALE_TIME_MS = 5_000; | ||
| 21 | const RESOURCE_TELEMETRY_IDLE_TTL_MS = 5 * 60_000; | ||
| 22 | const RESOURCE_TELEMETRY_HISTORY_INPUT_SEPARATOR = ":"; | ||
| 23 | |||
| 24 | interface ResourceTelemetryLiveState { | ||
| 25 | readonly data: ResourceTelemetrySnapshot | null; | ||
| 26 | readonly error: string | null; | ||
| 27 | readonly isPending: boolean; | ||
| 28 | } | ||
| 29 | |||
| 30 | const resourceTelemetryLiveAtom = Atom.make<ResourceTelemetryLiveState>({ | ||
| 31 | data: null, | ||
| 32 | error: null, | ||
| 33 | isPending: true, | ||
| 34 | }).pipe(Atom.keepAlive, Atom.withLabel("resource-telemetry-live")); | ||
| 35 | |||
| 36 | function formatResourceTelemetryError(error: unknown): string { | ||
| 37 | return error instanceof Error ? error.message : "Failed to load resource telemetry."; | ||
| 38 | } | ||
| 39 | |||
| 40 | function updateLiveState( | ||
| 41 | update: (current: ResourceTelemetryLiveState) => ResourceTelemetryLiveState, | ||
| 42 | ): void { | ||
| 43 | appAtomRegistry.set( | ||
| 44 | resourceTelemetryLiveAtom, | ||
| 45 | update(appAtomRegistry.get(resourceTelemetryLiveAtom)), | ||
| 46 | ); | ||
| 47 | } | ||
| 48 | |||
| 49 | let retainCount = 0; | ||
| 50 | let requestGeneration = 0; | ||
| 51 | let activeClient: object | null = null; | ||
| 52 | let unsubscribeTelemetry: () => void = () => undefined; | ||
| 53 | let unsubscribeConnections: () => void = () => undefined; | ||
| 54 | |||
| 55 | function applySnapshot(snapshot: ResourceTelemetrySnapshot): void { | ||
| 56 | updateLiveState(() => ({ | ||
| 57 | data: snapshot, | ||
| 58 | error: null, | ||
| 59 | isPending: false, | ||
| 60 | })); | ||
| 61 | } | ||
| 62 | |||
| 63 | function refreshFromCurrentClient(): void { | ||
| 64 | const generation = ++requestGeneration; | ||
| 65 | updateLiveState((current) => ({ | ||
| 66 | ...current, | ||
| 67 | error: null, | ||
| 68 | isPending: true, | ||
| 69 | })); | ||
| 70 | |||
| 71 | let server: ReturnType<typeof getPrimaryEnvironmentConnection>["client"]["server"]; | ||
| 72 | try { | ||
| 73 | server = getPrimaryEnvironmentConnection().client.server; | ||
| 74 | } catch (error) { | ||
| 75 | if (generation !== requestGeneration) return; | ||
| 76 | updateLiveState((current) => ({ | ||
| 77 | ...current, | ||
| 78 | error: formatResourceTelemetryError(error), | ||
| 79 | isPending: false, | ||
| 80 | })); | ||
| 81 | return; | ||
| 82 | } | ||
| 83 | |||
| 84 | void server.getResourceTelemetry().then( | ||
| 85 | (snapshot) => { | ||
| 86 | if (generation !== requestGeneration) return; | ||
| 87 | applySnapshot(snapshot); | ||
| 88 | }, | ||
| 89 | (error: unknown) => { | ||
| 90 | if (generation !== requestGeneration) return; | ||
| 91 | updateLiveState((current) => ({ | ||
| 92 | ...current, | ||
| 93 | error: formatResourceTelemetryError(error), | ||
| 94 | isPending: false, | ||
| 95 | })); | ||
| 96 | }, | ||
| 97 | ); | ||
| 98 | } | ||
| 99 | |||
| 100 | function attachResourceTelemetry(): void { | ||
| 101 | let client: ReturnType<typeof getPrimaryEnvironmentConnection>["client"]; | ||
| 102 | try { | ||
| 103 | client = getPrimaryEnvironmentConnection().client; | ||
| 104 | } catch (error) { | ||
| 105 | activeClient = null; | ||
| 106 | unsubscribeTelemetry(); | ||
| 107 | unsubscribeTelemetry = () => undefined; | ||
| 108 | updateLiveState((current) => ({ | ||
| 109 | ...current, | ||
| 110 | error: formatResourceTelemetryError(error), | ||
| 111 | isPending: false, | ||
| 112 | })); | ||
| 113 | return; | ||
| 114 | } | ||
| 115 | |||
| 116 | if (client === activeClient) { | ||
| 117 | return; | ||
| 118 | } | ||
| 119 | |||
| 120 | activeClient = client; | ||
| 121 | unsubscribeTelemetry(); | ||
| 122 | refreshFromCurrentClient(); | ||
| 123 | unsubscribeTelemetry = client.server.subscribeResourceTelemetry(applySnapshot, { | ||
| 124 | onResubscribe: refreshFromCurrentClient, | ||
| 125 | }); | ||
| 126 | } | ||
| 127 | |||
| 128 | function retainResourceTelemetry(): () => void { | ||
| 129 | retainCount += 1; | ||
| 130 | if (retainCount === 1) { | ||
| 131 | unsubscribeConnections = subscribeEnvironmentConnections(() => { | ||
| 132 | activeClient = null; | ||
| 133 | attachResourceTelemetry(); | ||
| 134 | }); | ||
| 135 | attachResourceTelemetry(); | ||
| 136 | } | ||
| 137 | |||
| 138 | return () => { | ||
| 139 | retainCount = Math.max(0, retainCount - 1); | ||
| 140 | if (retainCount !== 0) return; | ||
| 141 | requestGeneration += 1; | ||
| 142 | activeClient = null; | ||
| 143 | unsubscribeTelemetry(); | ||
| 144 | unsubscribeTelemetry = () => undefined; | ||
| 145 | unsubscribeConnections(); | ||
| 146 | unsubscribeConnections = () => undefined; | ||
| 147 | }; | ||
| 148 | } | ||
| 149 | |||
| 150 | function formatHistoryKey(input: ResourceTelemetryHistoryInput): string { | ||
| 151 | return `${input.windowMs}${RESOURCE_TELEMETRY_HISTORY_INPUT_SEPARATOR}${input.bucketMs}`; | ||
| 152 | } | ||
| 153 | |||
| 154 | function parseHistoryKey(key: string): ResourceTelemetryHistoryInput { | ||
| 155 | const [windowMs = "0", bucketMs = "0"] = key.split(RESOURCE_TELEMETRY_HISTORY_INPUT_SEPARATOR); | ||
| 156 | return { | ||
| 157 | windowMs: Number(windowMs), | ||
| 158 | bucketMs: Number(bucketMs), | ||
| 159 | }; | ||
| 160 | } | ||
| 161 | |||
| 162 | const resourceTelemetryHistoryAtom = Atom.family((key: string) => { | ||
| 163 | const input = parseHistoryKey(key); | ||
| 164 | return Atom.make( | ||
| 165 | Effect.promise(() => ensureLocalApi().server.getResourceTelemetryHistory(input)), | ||
| 166 | ).pipe( | ||
| 167 | Atom.swr({ | ||
| 168 | staleTime: RESOURCE_TELEMETRY_HISTORY_STALE_TIME_MS, | ||
| 169 | revalidateOnMount: true, | ||
| 170 | }), | ||
| 171 | Atom.setIdleTTL(RESOURCE_TELEMETRY_IDLE_TTL_MS), | ||
| 172 | Atom.withLabel(`resource-telemetry-history:${key}`), | ||
| 173 | ); | ||
| 174 | }); | ||
| 175 | |||
| 176 | export interface ResourceTelemetryState extends ResourceTelemetryLiveState { | ||
| 177 | readonly refresh: () => void; | ||
| 178 | readonly retry: () => Promise<ResourceTelemetrySnapshot>; | ||
| 179 | } | ||
| 180 | |||
| 181 | export interface ResourceTelemetryHistoryState { | ||
| 182 | readonly data: ResourceTelemetryHistory | null; | ||
| 183 | readonly error: string | null; | ||
| 184 | readonly isPending: boolean; | ||
| 185 | readonly refresh: () => void; | ||
| 186 | } | ||
| 187 | |||
| 188 | export function refreshResourceTelemetry(): void { | ||
| 189 | refreshFromCurrentClient(); | ||
| 190 | } | ||
| 191 | |||
| 192 | export async function retryResourceTelemetry(): Promise<ResourceTelemetrySnapshot> { | ||
| 193 | const result = await ensureLocalApi().server.retryResourceTelemetry(); | ||
| 194 | applySnapshot(result.snapshot); | ||
| 195 | return result.snapshot; | ||
| 196 | } | ||
| 197 | |||
| 198 | export function useResourceTelemetry(): ResourceTelemetryState { | ||
| 199 | useEffect(retainResourceTelemetry, []); | ||
| 200 | const state = useAtomValue(resourceTelemetryLiveAtom); | ||
| 201 | const refresh = useCallback(refreshResourceTelemetry, []); | ||
| 202 | const retry = useCallback(() => retryResourceTelemetry(), []); | ||
| 203 | |||
| 204 | return { | ||
| 205 | ...state, | ||
| 206 | refresh, | ||
| 207 | retry, | ||
| 208 | }; | ||
| 209 | } | ||
| 210 | |||
| 211 | export function useResourceTelemetryHistory( | ||
| 212 | input: ResourceTelemetryHistoryInput, | ||
| 213 | ): ResourceTelemetryHistoryState { | ||
| 214 | const atom = resourceTelemetryHistoryAtom(formatHistoryKey(input)); | ||
| 215 | const result = useAtomValue(atom); | ||
| 216 | const data = Option.getOrNull(AsyncResult.value(result)); | ||
| 217 | const refresh = useCallback(() => { | ||
| 218 | appAtomRegistry.refresh(atom); | ||
| 219 | }, [atom]); | ||
| 220 | |||
| 221 | return { | ||
| 222 | data, | ||
| 223 | error: | ||
| 224 | result._tag === "Failure" ? formatResourceTelemetryError(Cause.squash(result.cause)) : null, | ||
| 225 | isPending: result.waiting, | ||
| 226 | refresh, | ||
| 227 | }; | ||
| 228 | } | ||
| @@ -159,6 +159,18 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { · 158 unmodified lines | |||
| 159 | rpcClient | 159 | rpcClient |
| 160 | ? rpcClient.server.getProcessResourceHistory(input) | 160 | ? rpcClient.server.getProcessResourceHistory(input) |
| 161 | : Promise.reject(unavailableLocalBackendError()), | 161 | : Promise.reject(unavailableLocalBackendError()), |
| 162 | getResourceTelemetry: () => | ||
| 163 | rpcClient | ||
| 164 | ? rpcClient.server.getResourceTelemetry() | ||
| 165 | : Promise.reject(unavailableLocalBackendError()), | ||
| 166 | getResourceTelemetryHistory: (input) => | ||
| 167 | rpcClient | ||
| 168 | ? rpcClient.server.getResourceTelemetryHistory(input) | ||
| 169 | : Promise.reject(unavailableLocalBackendError()), | ||
| 170 | retryResourceTelemetry: () => | ||
| 171 | rpcClient | ||
| 172 | ? rpcClient.server.retryResourceTelemetry() | ||
| 173 | : Promise.reject(unavailableLocalBackendError()), | ||
| 162 | signalProcess: (input) => | 174 | signalProcess: (input) => |
| 163 | rpcClient | 175 | rpcClient |
| 164 | ? rpcClient.server.signalProcess(input) | 176 | ? rpcClient.server.signalProcess(input) |
| @@ -37,6 +37,10 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS · 36 unmodified lines | |||
| 37 | 37 | ||
| 38 | - **Runtime signals**: The server emits lightweight typed receipts when important async milestones finish, such as checkpoint capture, diff finalization, or a turn becoming fully quiescent. Tests and orchestration code wait on these signals instead of polling internal state. | 38 | - **Runtime signals**: The server emits lightweight typed receipts when important async milestones finish, such as checkpoint capture, diff finalization, or a turn becoming fully quiescent. Tests and orchestration code wait on these signals instead of polling internal state. |
| 39 | 39 | ||
| 40 | Related design: | ||
| 41 | |||
| 42 | - [Resource telemetry architecture](./resource-telemetry.md) | ||
| 43 | |||
| 40 | ## Event Lifecycle | 44 | ## Event Lifecycle |
| 41 | 45 | ||
| 42 | ### Startup and client connect | 46 | ### Startup and client connect |
| @@ -0,0 +1,335 @@ | |||
| 1 | # Resource telemetry architecture | ||
| 2 | |||
| 3 | Status: implemented | ||
| 4 | |||
| 5 | ## Purpose | ||
| 6 | |||
| 7 | Resource telemetry replaces recurring `ps`, PowerShell, `ioreg`, and `pmset` | ||
| 8 | subprocess probes with two persistent, direct data sources: | ||
| 9 | |||
| 10 | 1. a standalone Rust resource-monitor executable that reads process counters | ||
| 11 | through operating-system APIs via `sysinfo`; | ||
| 12 | 2. Electron main-process APIs for Electron process metrics and host power state. | ||
| 13 | |||
| 14 | The server merges both sources, computes rates from cumulative counters, keeps | ||
| 15 | bounded in-memory history, exposes typed RPCs, and drives the diagnostics page. | ||
| 16 | Telemetry history is not persisted to disk. | ||
| 17 | |||
| 18 | ## Why a standalone executable | ||
| 19 | |||
| 20 | The monitor is intentionally not a Node native addon. | ||
| 21 | |||
| 22 | - No N-API, `ffi-rs`, or dynamic-library ABI is loaded into the server process. | ||
| 23 | - A monitor crash cannot corrupt the Node runtime. | ||
| 24 | - The server can supervise, restart, version-check, and measure the monitor as a | ||
| 25 | normal child process. | ||
| 26 | - The same protocol works for the desktop app and the published CLI. | ||
| 27 | - Packaging is a single platform executable instead of an addon toolchain plus | ||
| 28 | Node/Electron ABI matrix. | ||
| 29 | |||
| 30 | The cost is one persistent child process and NDJSON serialization. That is a | ||
| 31 | better failure boundary than repeatedly spawning shell utilities or loading | ||
| 32 | native code into Node. | ||
| 33 | |||
| 34 | ## Runtime topology | ||
| 35 | |||
| 36 | ### Desktop | ||
| 37 | |||
| 38 | ```text | ||
| 39 | Electron main | ||
| 40 | ├─ powerMonitor | ||
| 41 | ├─ app.getAppMetrics() | ||
| 42 | └─ inherited fd 4, NDJSON | ||
| 43 | │ | ||
| 44 | ▼ | ||
| 45 | Node server ── stdin/stdout NDJSON ── Rust resource monitor | ||
| 46 | │ | ||
| 47 | ├─ ResourceTelemetry Effect service | ||
| 48 | ├─ bounded in-memory history | ||
| 49 | ├─ background power policy projection | ||
| 50 | └─ WebSocket RPC/subscription ── diagnostics UI | ||
| 51 | ``` | ||
| 52 | |||
| 53 | ### Web, headless, and remote server | ||
| 54 | |||
| 55 | Electron telemetry is unavailable. The native monitor still runs beside the | ||
| 56 | server and tracks the server process tree. Power fields degrade to `unknown` | ||
| 57 | instead of invoking platform shell commands. | ||
| 58 | |||
| 59 | ## Native monitor | ||
| 60 | |||
| 61 | The executable lives in `native/resource-monitor`. | ||
| 62 | |||
| 63 | It receives schema-compatible commands on stdin and emits one JSON object per | ||
| 64 | line on stdout: | ||
| 65 | |||
| 66 | - `configure` | ||
| 67 | - `setExternalProcesses` | ||
| 68 | - `sampleNow` | ||
| 69 | - `shutdown` | ||
| 70 | - `hello` | ||
| 71 | - `snapshot` | ||
| 72 | - `error` | ||
| 73 | |||
| 74 | The protocol version is defined by | ||
| 75 | `RESOURCE_MONITOR_PROTOCOL_VERSION` in | ||
| 76 | `packages/contracts/src/resourceTelemetry.ts`. | ||
| 77 | |||
| 78 | ### Collection | ||
| 79 | |||
| 80 | The monitor keeps one `sysinfo::System` instance and refreshes it on a one-second | ||
| 81 | interval. It collects: | ||
| 82 | |||
| 83 | - PID and parent PID; | ||
| 84 | - process start time and run time; | ||
| 85 | - process name and command line; | ||
| 86 | - current and cumulative CPU usage; | ||
| 87 | - resident and virtual memory; | ||
| 88 | - cumulative process I/O counters. | ||
| 89 | |||
| 90 | On Linux, task/thread enumeration is disabled. Command lines are loaded only | ||
| 91 | when first needed. This avoids the expensive default behavior of walking every | ||
| 92 | `/proc/<pid>/task/<tid>` directory on each refresh. | ||
| 93 | |||
| 94 | ### Process-tree selection | ||
| 95 | |||
| 96 | Each sample scans the accessible process table, builds the PID/PPID graph, and | ||
| 97 | retains: | ||
| 98 | |||
| 99 | - the server process; | ||
| 100 | - every descendant of the server, including provider-spawned grandchildren such | ||
| 101 | as shells, `node`, `tsgo`, language servers, and other tools; | ||
| 102 | - Electron processes supplied as explicit external roots; | ||
| 103 | - descendants of those Electron roots; | ||
| 104 | - the resource monitor itself, because it is a server child. | ||
| 105 | |||
| 106 | Process identity is `(pid, startTimeMs)`, not PID alone. Electron and native | ||
| 107 | start times are matched with a two-second tolerance because native start times | ||
| 108 | can have coarser platform resolution. | ||
| 109 | |||
| 110 | The process list is emitted in depth-first tree order so renderer collapse and | ||
| 111 | expansion preserves complete subtrees. | ||
| 112 | |||
| 113 | ### Sampling limits | ||
| 114 | |||
| 115 | This is counter sampling, not syscall tracing. | ||
| 116 | |||
| 117 | - A process that starts and exits entirely between samples may not be observed. | ||
| 118 | - Cumulative CPU and I/O counters still provide accurate deltas for processes | ||
| 119 | that survive across samples. | ||
| 120 | - Exact file paths, individual write syscalls, ETW events, eBPF events, and | ||
| 121 | Endpoint Security events are outside this implementation. | ||
| 122 | |||
| 123 | Those deeper tracing systems can be added later as opt-in diagnostic modes | ||
| 124 | without changing the public `ResourceTelemetry` model. | ||
| 125 | |||
| 126 | ## I/O semantics | ||
| 127 | |||
| 128 | The monitor preserves platform semantics instead of presenting all counters as | ||
| 129 | equivalent: | ||
| 130 | |||
| 131 | - Unix-like platforms report storage I/O counters exposed by `sysinfo`. | ||
| 132 | - Windows reports all process I/O bytes, not only disk bytes. | ||
| 133 | - Operating-system caches can prevent logical application reads or writes from | ||
| 134 | appearing as physical storage bytes. | ||
| 135 | |||
| 136 | The UI therefore labels these values as I/O reads and writes and exposes the | ||
| 137 | per-process `ioSemantics` value. | ||
| 138 | |||
| 139 | Group totals are observed deltas since telemetry startup. Per-process total | ||
| 140 | columns are the operating system's cumulative counters for that process. | ||
| 141 | |||
| 142 | ## Electron telemetry | ||
| 143 | |||
| 144 | Electron main owns `DesktopTelemetryPublisher`. | ||
| 145 | |||
| 146 | It samples once per second from: | ||
| 147 | |||
| 148 | - `app.getAppMetrics()`; | ||
| 149 | - `powerMonitor.isOnBatteryPower()`; | ||
| 150 | - `powerMonitor.getSystemIdleTime()`; | ||
| 151 | - `powerMonitor.getSystemIdleState()`; | ||
| 152 | - `powerMonitor.getCurrentThermalState()`. | ||
| 153 | |||
| 154 | It also listens for: | ||
| 155 | |||
| 156 | - lock and unlock; | ||
| 157 | - suspend and resume; | ||
| 158 | - AC and battery transitions; | ||
| 159 | - thermal-state changes; | ||
| 160 | - CPU speed-limit changes. | ||
| 161 | |||
| 162 | Electron does not expose a cross-platform low-power-mode getter, so that field | ||
| 163 | remains `unknown`. | ||
| 164 | |||
| 165 | The desktop backend is spawned with: | ||
| 166 | |||
| 167 | - fd 3 for the existing bootstrap payload; | ||
| 168 | - fd 4 for desktop telemetry NDJSON. | ||
| 169 | |||
| 170 | This is a private Electron-main-to-server pipe. It does not use the renderer | ||
| 171 | WebSocket and is recreated for every backend restart. | ||
| 172 | |||
| 173 | ## Server Effect services | ||
| 174 | |||
| 175 | The implementation is under `apps/server/src/resourceTelemetry`. | ||
| 176 | |||
| 177 | ### `ResourceMonitorBinary` | ||
| 178 | |||
| 179 | Resolves an executable from: | ||
| 180 | |||
| 181 | 1. `T3CODE_RESOURCE_MONITOR_PATH`; | ||
| 182 | 2. desktop bootstrap configuration; | ||
| 183 | 3. bundled CLI resources; | ||
| 184 | 4. local Cargo build outputs. | ||
| 185 | |||
| 186 | Unsupported platforms, missing binaries, and non-executable binaries use | ||
| 187 | schema-backed tagged errors with descriptive messages. | ||
| 188 | |||
| 189 | ### `NativeTelemetryClient` | ||
| 190 | |||
| 191 | Owns the resource-monitor process and protocol. | ||
| 192 | |||
| 193 | - validates the hello/version handshake; | ||
| 194 | - sends configuration and external process roots; | ||
| 195 | - exposes automatic snapshots and `sampleNow`; | ||
| 196 | - serializes commands; | ||
| 197 | - supervises process exit and protocol failure; | ||
| 198 | - restarts with bounded exponential backoff; | ||
| 199 | - opens a circuit after repeated failures; | ||
| 200 | - supports explicit retry; | ||
| 201 | - publishes health changes immediately. | ||
| 202 | |||
| 203 | Snapshot sequence numbers are scoped to a monitor generation. Server ingestion | ||
| 204 | uses the monitor restart count as the generation key, so sequence reset after a | ||
| 205 | restart cannot freeze telemetry. | ||
| 206 | |||
| 207 | ### `DesktopTelemetryReceiver` | ||
| 208 | |||
| 209 | Reads fd 4, decodes schema-validated messages, stores the latest Electron | ||
| 210 | snapshot, and publishes desktop health. Decode errors, protocol mismatch, | ||
| 211 | stream failure, and normal stream closure are represented explicitly. | ||
| 212 | |||
| 213 | ### `ResourceTelemetry` | ||
| 214 | |||
| 215 | Merges native and Electron data and owns public telemetry semantics. | ||
| 216 | |||
| 217 | - calculates CPU and I/O rates from cumulative native counters; | ||
| 218 | - preserves the last native rates during desktop-only updates; | ||
| 219 | - classifies backend, Electron, and monitor processes; | ||
| 220 | - computes process depth and child relationships; | ||
| 221 | - tracks starts, exits, CPU time, and observed I/O; | ||
| 222 | - projects power data; | ||
| 223 | - publishes live snapshots; | ||
| 224 | - validates `(pid, startTimeMs)` before process signaling; | ||
| 225 | - updates history health even when no further native sample arrives. | ||
| 226 | |||
| 227 | Electron and monitor processes are visible but are not valid targets for the | ||
| 228 | existing process-signal RPC. | ||
| 229 | |||
| 230 | ### `ResourceTelemetryStore` | ||
| 231 | |||
| 232 | Keeps aggregate and process samples in memory for at most one hour, subject to | ||
| 233 | hard sample-count bounds. Aggregate history retains up to 3,600 samples. | ||
| 234 | Detailed process history retains up to 20,000 process samples, so high process | ||
| 235 | fan-out can shorten detailed per-process coverage while aggregate coverage | ||
| 236 | remains available. | ||
| 237 | |||
| 238 | ### `ResourceAttribution` | ||
| 239 | |||
| 240 | Tracks known logical application I/O separately from OS counters. Current | ||
| 241 | integration points record successful writes for: | ||
| 242 | |||
| 243 | - provider native and canonical event logs; | ||
| 244 | - the local server trace sink. | ||
| 245 | |||
| 246 | Entries contain component, operation, logical bytes, count, and elapsed time. | ||
| 247 | Future persistence paths should call `ResourceAttribution.record` rather than | ||
| 248 | adding diagnostics-specific counters. | ||
| 249 | |||
| 250 | ## Background policy integration | ||
| 251 | |||
| 252 | `HostPowerMonitor` now projects power state from `ResourceTelemetry`; it does not | ||
| 253 | spawn macOS shell probes. | ||
| 254 | |||
| 255 | The monitor updates its latest timestamp on every Electron sample but only | ||
| 256 | publishes semantic state changes. Increasing idle seconds alone does not cause a | ||
| 257 | background-policy broadcast every second. | ||
| 258 | |||
| 259 | ## Public API and UI | ||
| 260 | |||
| 261 | The WebSocket RPC surface provides: | ||
| 262 | |||
| 263 | - current snapshot; | ||
| 264 | - bounded history; | ||
| 265 | - explicit monitor retry; | ||
| 266 | - a live snapshot subscription. | ||
| 267 | |||
| 268 | The diagnostics page displays: | ||
| 269 | |||
| 270 | - aggregate CPU, memory, I/O, and process counts; | ||
| 271 | - backend, Electron, and monitor overhead groups; | ||
| 272 | - power and thermal state; | ||
| 273 | - collector health and restart information; | ||
| 274 | - CPU and I/O history; | ||
| 275 | - a collapsible live process tree; | ||
| 276 | - safe process signaling for backend descendants; | ||
| 277 | - instrumented logical application I/O. | ||
| 278 | |||
| 279 | Legacy process diagnostics RPCs are projected from the same service so they no | ||
| 280 | longer start recurring process-table commands. | ||
| 281 | |||
| 282 | ## Packaging | ||
| 283 | |||
| 284 | Desktop artifact builds compile the Rust target, stage it as | ||
| 285 | `resources/resource-monitor/t3-resource-monitor[.exe]`, and pass its path to the | ||
| 286 | backend bootstrap. | ||
| 287 | |||
| 288 | CLI release jobs upload each active platform monitor artifact and copy it into: | ||
| 289 | |||
| 290 | ```text | ||
| 291 | apps/server/dist/resource-monitor/<platform>-<arch>/ | ||
| 292 | ``` | ||
| 293 | |||
| 294 | The published server package already includes `dist`, so those executables ship | ||
| 295 | with the CLI. Missing platform artifacts degrade native telemetry to | ||
| 296 | `unavailable`; the server continues running. | ||
| 297 | |||
| 298 | ## Resource and failure behavior | ||
| 299 | |||
| 300 | Steady state uses: | ||
| 301 | |||
| 302 | - one native process; | ||
| 303 | - one native sample per second; | ||
| 304 | - one Electron sample per second in desktop mode; | ||
| 305 | - no telemetry database; | ||
| 306 | - no recurring shell probes; | ||
| 307 | - bounded PubSub queues and bounded history. | ||
| 308 | |||
| 309 | The diagnostics page exposes the monitor's own process resource usage and | ||
| 310 | collection duration so the observer's cost is measurable. | ||
| 311 | |||
| 312 | Failures are isolated: | ||
| 313 | |||
| 314 | - native failure does not stop the server; | ||
| 315 | - Electron telemetry loss does not stop native telemetry; | ||
| 316 | - schema/version errors are visible in health; | ||
| 317 | - repeated native failures stop automatic restart churn until explicit retry; | ||
| 318 | - server and desktop shutdown close their respective streams and child process | ||
| 319 | scopes. | ||
| 320 | |||
| 321 | ## Future integration points | ||
| 322 | |||
| 323 | High-value follow-up work can use the existing service boundaries: | ||
| 324 | |||
| 325 | - opt-in file-path attribution through platform-specific tracing; | ||
| 326 | - process lifecycle events to reduce the chance of missing very short-lived | ||
| 327 | children; | ||
| 328 | - additional `ResourceAttribution` instrumentation for databases, checkpoints, | ||
| 329 | caches, and file synchronization; | ||
| 330 | - exported diagnostic bundles; | ||
| 331 | - adaptive sample intervals based on diagnostics visibility and active work. | ||
| 332 | |||
| 333 | These additions should preserve the current rules: direct platform APIs, | ||
| 334 | schema-validated boundaries, explicit metric semantics, bounded retention, and | ||
| 335 | no mandatory telemetry persistence. | ||
| @@ -0,0 +1,343 @@ | |||
| 1 | # This file is automatically @generated by Cargo. | ||
| 2 | # It is not intended for manual editing. | ||
| 3 | version = 4 | ||
| 4 | |||
| 5 | [[package]] | ||
| 6 | name = "bitflags" | ||
| 7 | version = "2.13.0" | ||
| 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 9 | checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" | ||
| 10 | |||
| 11 | [[package]] | ||
| 12 | name = "dispatch2" | ||
| 13 | version = "0.3.1" | ||
| 14 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 15 | checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" | ||
| 16 | dependencies = [ | ||
| 17 | "bitflags", | ||
| 18 | "objc2", | ||
| 19 | ] | ||
| 20 | |||
| 21 | [[package]] | ||
| 22 | name = "itoa" | ||
| 23 | version = "1.0.18" | ||
| 24 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 25 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | ||
| 26 | |||
| 27 | [[package]] | ||
| 28 | name = "libc" | ||
| 29 | version = "0.2.186" | ||
| 30 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 31 | checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" | ||
| 32 | |||
| 33 | [[package]] | ||
| 34 | name = "memchr" | ||
| 35 | version = "2.8.2" | ||
| 36 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 37 | checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" | ||
| 38 | |||
| 39 | [[package]] | ||
| 40 | name = "ntapi" | ||
| 41 | version = "0.4.3" | ||
| 42 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 43 | checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" | ||
| 44 | dependencies = [ | ||
| 45 | "winapi", | ||
| 46 | ] | ||
| 47 | |||
| 48 | [[package]] | ||
| 49 | name = "objc2" | ||
| 50 | version = "0.6.4" | ||
| 51 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 52 | checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" | ||
| 53 | dependencies = [ | ||
| 54 | "objc2-encode", | ||
| 55 | ] | ||
| 56 | |||
| 57 | [[package]] | ||
| 58 | name = "objc2-core-foundation" | ||
| 59 | version = "0.3.2" | ||
| 60 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 61 | checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" | ||
| 62 | dependencies = [ | ||
| 63 | "bitflags", | ||
| 64 | "dispatch2", | ||
| 65 | "objc2", | ||
| 66 | ] | ||
| 67 | |||
| 68 | [[package]] | ||
| 69 | name = "objc2-encode" | ||
| 70 | version = "4.1.0" | ||
| 71 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 72 | checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" | ||
| 73 | |||
| 74 | [[package]] | ||
| 75 | name = "objc2-foundation" | ||
| 76 | version = "0.3.2" | ||
| 77 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 78 | checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" | ||
| 79 | dependencies = [ | ||
| 80 | "bitflags", | ||
| 81 | "objc2", | ||
| 82 | ] | ||
| 83 | |||
| 84 | [[package]] | ||
| 85 | name = "objc2-io-kit" | ||
| 86 | version = "0.3.2" | ||
| 87 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 88 | checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" | ||
| 89 | dependencies = [ | ||
| 90 | "libc", | ||
| 91 | "objc2-core-foundation", | ||
| 92 | ] | ||
| 93 | |||
| 94 | [[package]] | ||
| 95 | name = "objc2-open-directory" | ||
| 96 | version = "0.3.2" | ||
| 97 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 98 | checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" | ||
| 99 | dependencies = [ | ||
| 100 | "objc2", | ||
| 101 | "objc2-core-foundation", | ||
| 102 | "objc2-foundation", | ||
| 103 | ] | ||
| 104 | |||
| 105 | [[package]] | ||
| 106 | name = "proc-macro2" | ||
| 107 | version = "1.0.106" | ||
| 108 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 109 | checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" | ||
| 110 | dependencies = [ | ||
| 111 | "unicode-ident", | ||
| 112 | ] | ||
| 113 | |||
| 114 | [[package]] | ||
| 115 | name = "quote" | ||
| 116 | version = "1.0.45" | ||
| 117 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 118 | checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" | ||
| 119 | dependencies = [ | ||
| 120 | "proc-macro2", | ||
| 121 | ] | ||
| 122 | |||
| 123 | [[package]] | ||
| 124 | name = "serde" | ||
| 125 | version = "1.0.228" | ||
| 126 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 127 | checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" | ||
| 128 | dependencies = [ | ||
| 129 | "serde_core", | ||
| 130 | "serde_derive", | ||
| 131 | ] | ||
| 132 | |||
| 133 | [[package]] | ||
| 134 | name = "serde_core" | ||
| 135 | version = "1.0.228" | ||
| 136 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 137 | checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" | ||
| 138 | dependencies = [ | ||
| 139 | "serde_derive", | ||
| 140 | ] | ||
| 141 | |||
| 142 | [[package]] | ||
| 143 | name = "serde_derive" | ||
| 144 | version = "1.0.228" | ||
| 145 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 146 | checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" | ||
| 147 | dependencies = [ | ||
| 148 | "proc-macro2", | ||
| 149 | "quote", | ||
| 150 | "syn", | ||
| 151 | ] | ||
| 152 | |||
| 153 | [[package]] | ||
| 154 | name = "serde_json" | ||
| 155 | version = "1.0.150" | ||
| 156 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 157 | checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" | ||
| 158 | dependencies = [ | ||
| 159 | "itoa", | ||
| 160 | "memchr", | ||
| 161 | "serde", | ||
| 162 | "serde_core", | ||
| 163 | "zmij", | ||
| 164 | ] | ||
| 165 | |||
| 166 | [[package]] | ||
| 167 | name = "syn" | ||
| 168 | version = "2.0.118" | ||
| 169 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 170 | checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" | ||
| 171 | dependencies = [ | ||
| 172 | "proc-macro2", | ||
| 173 | "quote", | ||
| 174 | "unicode-ident", | ||
| 175 | ] | ||
| 176 | |||
| 177 | [[package]] | ||
| 178 | name = "sysinfo" | ||
| 179 | version = "0.39.3" | ||
| 180 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 181 | checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96" | ||
| 182 | dependencies = [ | ||
| 183 | "libc", | ||
| 184 | "memchr", | ||
| 185 | "ntapi", | ||
| 186 | "objc2-core-foundation", | ||
| 187 | "objc2-io-kit", | ||
| 188 | "objc2-open-directory", | ||
| 189 | "windows", | ||
| 190 | ] | ||
| 191 | |||
| 192 | [[package]] | ||
| 193 | name = "t3-resource-monitor" | ||
| 194 | version = "0.1.0" | ||
| 195 | dependencies = [ | ||
| 196 | "serde", | ||
| 197 | "serde_json", | ||
| 198 | "sysinfo", | ||
| 199 | ] | ||
| 200 | |||
| 201 | [[package]] | ||
| 202 | name = "unicode-ident" | ||
| 203 | version = "1.0.24" | ||
| 204 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 205 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | ||
| 206 | |||
| 207 | [[package]] | ||
| 208 | name = "winapi" | ||
| 209 | version = "0.3.9" | ||
| 210 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 211 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" | ||
| 212 | dependencies = [ | ||
| 213 | "winapi-i686-pc-windows-gnu", | ||
| 214 | "winapi-x86_64-pc-windows-gnu", | ||
| 215 | ] | ||
| 216 | |||
| 217 | [[package]] | ||
| 218 | name = "winapi-i686-pc-windows-gnu" | ||
| 219 | version = "0.4.0" | ||
| 220 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 221 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | ||
| 222 | |||
| 223 | [[package]] | ||
| 224 | name = "winapi-x86_64-pc-windows-gnu" | ||
| 225 | version = "0.4.0" | ||
| 226 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 227 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" | ||
| 228 | |||
| 229 | [[package]] | ||
| 230 | name = "windows" | ||
| 231 | version = "0.62.2" | ||
| 232 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 233 | checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" | ||
| 234 | dependencies = [ | ||
| 235 | "windows-collections", | ||
| 236 | "windows-core", | ||
| 237 | "windows-future", | ||
| 238 | "windows-numerics", | ||
| 239 | ] | ||
| 240 | |||
| 241 | [[package]] | ||
| 242 | name = "windows-collections" | ||
| 243 | version = "0.3.2" | ||
| 244 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 245 | checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" | ||
| 246 | dependencies = [ | ||
| 247 | "windows-core", | ||
| 248 | ] | ||
| 249 | |||
| 250 | [[package]] | ||
| 251 | name = "windows-core" | ||
| 252 | version = "0.62.2" | ||
| 253 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 254 | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" | ||
| 255 | dependencies = [ | ||
| 256 | "windows-implement", | ||
| 257 | "windows-interface", | ||
| 258 | "windows-link", | ||
| 259 | "windows-result", | ||
| 260 | "windows-strings", | ||
| 261 | ] | ||
| 262 | |||
| 263 | [[package]] | ||
| 264 | name = "windows-future" | ||
| 265 | version = "0.3.2" | ||
| 266 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 267 | checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" | ||
| 268 | dependencies = [ | ||
| 269 | "windows-core", | ||
| 270 | "windows-link", | ||
| 271 | "windows-threading", | ||
| 272 | ] | ||
| 273 | |||
| 274 | [[package]] | ||
| 275 | name = "windows-implement" | ||
| 276 | version = "0.60.2" | ||
| 277 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 278 | checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" | ||
| 279 | dependencies = [ | ||
| 280 | "proc-macro2", | ||
| 281 | "quote", | ||
| 282 | "syn", | ||
| 283 | ] | ||
| 284 | |||
| 285 | [[package]] | ||
| 286 | name = "windows-interface" | ||
| 287 | version = "0.59.3" | ||
| 288 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 289 | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" | ||
| 290 | dependencies = [ | ||
| 291 | "proc-macro2", | ||
| 292 | "quote", | ||
| 293 | "syn", | ||
| 294 | ] | ||
| 295 | |||
| 296 | [[package]] | ||
| 297 | name = "windows-link" | ||
| 298 | version = "0.2.1" | ||
| 299 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 300 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | ||
| 301 | |||
| 302 | [[package]] | ||
| 303 | name = "windows-numerics" | ||
| 304 | version = "0.3.1" | ||
| 305 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 306 | checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" | ||
| 307 | dependencies = [ | ||
| 308 | "windows-core", | ||
| 309 | "windows-link", | ||
| 310 | ] | ||
| 311 | |||
| 312 | [[package]] | ||
| 313 | name = "windows-result" | ||
| 314 | version = "0.4.1" | ||
| 315 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 316 | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" | ||
| 317 | dependencies = [ | ||
| 318 | "windows-link", | ||
| 319 | ] | ||
| 320 | |||
| 321 | [[package]] | ||
| 322 | name = "windows-strings" | ||
| 323 | version = "0.5.1" | ||
| 324 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 325 | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" | ||
| 326 | dependencies = [ | ||
| 327 | "windows-link", | ||
| 328 | ] | ||
| 329 | |||
| 330 | [[package]] | ||
| 331 | name = "windows-threading" | ||
| 332 | version = "0.2.1" | ||
| 333 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 334 | checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" | ||
| 335 | dependencies = [ | ||
| 336 | "windows-link", | ||
| 337 | ] | ||
| 338 | |||
| 339 | [[package]] | ||
| 340 | name = "zmij" | ||
| 341 | version = "1.0.21" | ||
| 342 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 343 | checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" | ||
| @@ -0,0 +1,17 @@ | |||
| 1 | [package] | ||
| 2 | name = "t3-resource-monitor" | ||
| 3 | version = "0.1.0" | ||
| 4 | edition = "2024" | ||
| 5 | license = "MIT" | ||
| 6 | publish = false | ||
| 7 | |||
| 8 | [dependencies] | ||
| 9 | serde = { version = "1.0.228", features = ["derive"] } | ||
| 10 | serde_json = "1.0.150" | ||
| 11 | sysinfo = "0.39.3" | ||
| 12 | |||
| 13 | [profile.release] | ||
| 14 | codegen-units = 1 | ||
| 15 | lto = "thin" | ||
| 16 | panic = "abort" | ||
| 17 | strip = true | ||
| @@ -0,0 +1,560 @@ | |||
| 1 | use serde::{Deserialize, Serialize}; | ||
| 2 | use std::collections::{HashMap, HashSet, VecDeque}; | ||
| 3 | use std::io::{self, BufRead, BufWriter, Write}; | ||
| 4 | use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; | ||
| 5 | use std::thread; | ||
| 6 | use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; | ||
| 7 | use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; | ||
| 8 | |||
| 9 | const PROTOCOL_VERSION: u32 = 1; | ||
| 10 | const MIN_SAMPLE_INTERVAL_MS: u64 = 250; | ||
| 11 | const MAX_SAMPLE_INTERVAL_MS: u64 = 60_000; | ||
| 12 | const EXTERNAL_PROCESS_START_TOLERANCE_MS: u64 = 2_000; | ||
| 13 | |||
| 14 | #[derive(Debug, Clone, Deserialize)] | ||
| 15 | #[serde(rename_all = "camelCase")] | ||
| 16 | struct ExternalProcess { | ||
| 17 | pid: u32, | ||
| 18 | #[serde(default)] | ||
| 19 | start_time_ms: Option<u64>, | ||
| 20 | } | ||
| 21 | |||
| 22 | #[derive(Debug, Deserialize)] | ||
| 23 | #[serde( | ||
| 24 | tag = "type", | ||
| 25 | rename_all = "camelCase", | ||
| 26 | rename_all_fields = "camelCase" | ||
| 27 | )] | ||
| 28 | enum Command { | ||
| 29 | Configure { | ||
| 30 | version: u32, | ||
| 31 | root_pid: u32, | ||
| 32 | sample_interval_ms: u64, | ||
| 33 | #[serde(default)] | ||
| 34 | external_processes: Vec<ExternalProcess>, | ||
| 35 | }, | ||
| 36 | SetExternalProcesses { | ||
| 37 | version: u32, | ||
| 38 | processes: Vec<ExternalProcess>, | ||
| 39 | }, | ||
| 40 | SampleNow { | ||
| 41 | version: u32, | ||
| 42 | request_id: String, | ||
| 43 | }, | ||
| 44 | Shutdown { | ||
| 45 | version: u32, | ||
| 46 | }, | ||
| 47 | } | ||
| 48 | |||
| 49 | impl Command { | ||
| 50 | fn version(&self) -> u32 { | ||
| 51 | match self { | ||
| 52 | Self::Configure { version, .. } | ||
| 53 | | Self::SetExternalProcesses { version, .. } | ||
| 54 | | Self::SampleNow { version, .. } | ||
| 55 | | Self::Shutdown { version } => *version, | ||
| 56 | } | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | enum Input { | ||
| 61 | Command(Command), | ||
| 62 | Invalid(String), | ||
| 63 | } | ||
| 64 | |||
| 65 | #[derive(Debug, Serialize)] | ||
| 66 | #[serde(rename_all = "camelCase")] | ||
| 67 | struct Capabilities { | ||
| 68 | cumulative_cpu_time: bool, | ||
| 69 | current_cpu_percent: bool, | ||
| 70 | resident_memory: bool, | ||
| 71 | virtual_memory: bool, | ||
| 72 | io_bytes: bool, | ||
| 73 | process_start_time: bool, | ||
| 74 | process_tree: bool, | ||
| 75 | } | ||
| 76 | |||
| 77 | #[derive(Debug, Serialize)] | ||
| 78 | #[serde(rename_all = "camelCase")] | ||
| 79 | struct HelloEvent { | ||
| 80 | version: u32, | ||
| 81 | #[serde(rename = "type")] | ||
| 82 | event_type: &'static str, | ||
| 83 | sidecar_version: &'static str, | ||
| 84 | sidecar_pid: u32, | ||
| 85 | platform: &'static str, | ||
| 86 | arch: &'static str, | ||
| 87 | capabilities: Capabilities, | ||
| 88 | } | ||
| 89 | |||
| 90 | #[derive(Debug, Clone, Copy, Serialize)] | ||
| 91 | #[serde(rename_all = "kebab-case")] | ||
| 92 | enum IoSemantics { | ||
| 93 | Storage, | ||
| 94 | AllIo, | ||
| 95 | } | ||
| 96 | |||
| 97 | #[derive(Debug, Serialize)] | ||
| 98 | #[serde(rename_all = "camelCase")] | ||
| 99 | struct ProcessSample { | ||
| 100 | pid: u32, | ||
| 101 | ppid: u32, | ||
| 102 | start_time_ms: u64, | ||
| 103 | run_time_ms: u64, | ||
| 104 | name: String, | ||
| 105 | command: String, | ||
| 106 | status: String, | ||
| 107 | cpu_percent: f32, | ||
| 108 | cpu_time_ms: u64, | ||
| 109 | resident_bytes: u64, | ||
| 110 | virtual_bytes: u64, | ||
| 111 | io_read_bytes: u64, | ||
| 112 | io_write_bytes: u64, | ||
| 113 | io_semantics: IoSemantics, | ||
| 114 | } | ||
| 115 | |||
| 116 | #[derive(Debug, Serialize)] | ||
| 117 | #[serde(rename_all = "camelCase")] | ||
| 118 | struct SnapshotEvent { | ||
| 119 | version: u32, | ||
| 120 | #[serde(rename = "type")] | ||
| 121 | event_type: &'static str, | ||
| 122 | sequence: u64, | ||
| 123 | sampled_at_unix_ms: u64, | ||
| 124 | collection_duration_micros: u64, | ||
| 125 | scanned_process_count: usize, | ||
| 126 | retained_process_count: usize, | ||
| 127 | inaccessible_process_count: usize, | ||
| 128 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 129 | request_id: Option<String>, | ||
| 130 | processes: Vec<ProcessSample>, | ||
| 131 | } | ||
| 132 | |||
| 133 | #[derive(Debug, Serialize)] | ||
| 134 | #[serde(rename_all = "camelCase")] | ||
| 135 | struct ErrorEvent { | ||
| 136 | version: u32, | ||
| 137 | #[serde(rename = "type")] | ||
| 138 | event_type: &'static str, | ||
| 139 | code: &'static str, | ||
| 140 | message: String, | ||
| 141 | recoverable: bool, | ||
| 142 | } | ||
| 143 | |||
| 144 | #[derive(Debug, Clone)] | ||
| 145 | struct CollectorConfig { | ||
| 146 | root_pid: u32, | ||
| 147 | sample_interval: Duration, | ||
| 148 | external_processes: HashMap<u32, Option<u64>>, | ||
| 149 | } | ||
| 150 | |||
| 151 | struct Collector { | ||
| 152 | system: System, | ||
| 153 | sequence: u64, | ||
| 154 | } | ||
| 155 | |||
| 156 | impl Collector { | ||
| 157 | fn new() -> Self { | ||
| 158 | Self { | ||
| 159 | system: System::new(), | ||
| 160 | sequence: 0, | ||
| 161 | } | ||
| 162 | } | ||
| 163 | |||
| 164 | fn sample(&mut self, config: &CollectorConfig, request_id: Option<String>) -> SnapshotEvent { | ||
| 165 | let collection_started = Instant::now(); | ||
| 166 | self.system.refresh_processes_specifics( | ||
| 167 | ProcessesToUpdate::All, | ||
| 168 | true, | ||
| 169 | process_refresh_kind(), | ||
| 170 | ); | ||
| 171 | |||
| 172 | let rows = self | ||
| 173 | .system | ||
| 174 | .processes() | ||
| 175 | .iter() | ||
| 176 | .map(|(pid, process)| { | ||
| 177 | let pid = pid.as_u32(); | ||
| 178 | let ppid = process.parent().map(Pid::as_u32).unwrap_or(0); | ||
| 179 | (pid, ppid, process.start_time().saturating_mul(1_000)) | ||
| 180 | }) | ||
| 181 | .collect::<Vec<_>>(); | ||
| 182 | let mut roots = config | ||
| 183 | .external_processes | ||
| 184 | .iter() | ||
| 185 | .filter_map(|(pid, expected_start_time_ms)| { | ||
| 186 | let (_, _, actual_start_time_ms) = rows | ||
| 187 | .iter() | ||
| 188 | .find(|(candidate_pid, _, _)| candidate_pid == pid)?; | ||
| 189 | matches_external_identity(*actual_start_time_ms, *expected_start_time_ms) | ||
| 190 | .then_some(*pid) | ||
| 191 | }) | ||
| 192 | .collect::<HashSet<_>>(); | ||
| 193 | roots.insert(config.root_pid); | ||
| 194 | let tracked = select_tracked_pids(&rows, &roots); | ||
| 195 | let mut processes = tracked | ||
| 196 | .into_iter() | ||
| 197 | .filter_map(|pid| { | ||
| 198 | let process = self.system.process(Pid::from_u32(pid))?; | ||
| 199 | let disk_usage = process.disk_usage(); | ||
| 200 | let command = if process.cmd().is_empty() { | ||
| 201 | process.name().to_string_lossy().into_owned() | ||
| 202 | } else { | ||
| 203 | process | ||
| 204 | .cmd() | ||
| 205 | .iter() | ||
| 206 | .map(|part| part.to_string_lossy()) | ||
| 207 | .collect::<Vec<_>>() | ||
| 208 | .join(" ") | ||
| 209 | }; | ||
| 210 | |||
| 211 | Some(ProcessSample { | ||
| 212 | pid, | ||
| 213 | ppid: process.parent().map(Pid::as_u32).unwrap_or(0), | ||
| 214 | start_time_ms: process.start_time().saturating_mul(1_000), | ||
| 215 | run_time_ms: process.run_time().saturating_mul(1_000), | ||
| 216 | name: process.name().to_string_lossy().into_owned(), | ||
| 217 | command, | ||
| 218 | status: format!("{:?}", process.status()), | ||
| 219 | cpu_percent: process.cpu_usage(), | ||
| 220 | cpu_time_ms: process.accumulated_cpu_time(), | ||
| 221 | resident_bytes: process.memory(), | ||
| 222 | virtual_bytes: process.virtual_memory(), | ||
| 223 | io_read_bytes: disk_usage.total_read_bytes, | ||
| 224 | io_write_bytes: disk_usage.total_written_bytes, | ||
| 225 | io_semantics: io_semantics(), | ||
| 226 | }) | ||
| 227 | }) | ||
| 228 | .collect::<Vec<_>>(); | ||
| 229 | processes.sort_by_key(|process| process.pid); | ||
| 230 | self.sequence = self.sequence.saturating_add(1); | ||
| 231 | |||
| 232 | SnapshotEvent { | ||
| 233 | version: PROTOCOL_VERSION, | ||
| 234 | event_type: "snapshot", | ||
| 235 | sequence: self.sequence, | ||
| 236 | sampled_at_unix_ms: unix_time_ms(), | ||
| 237 | collection_duration_micros: collection_started.elapsed().as_micros() as u64, | ||
| 238 | scanned_process_count: self.system.processes().len(), | ||
| 239 | retained_process_count: processes.len(), | ||
| 240 | inaccessible_process_count: 0, | ||
| 241 | request_id, | ||
| 242 | processes, | ||
| 243 | } | ||
| 244 | } | ||
| 245 | } | ||
| 246 | |||
| 247 | fn process_refresh_kind() -> ProcessRefreshKind { | ||
| 248 | ProcessRefreshKind::nothing() | ||
| 249 | .with_memory() | ||
| 250 | .with_cpu() | ||
| 251 | .with_disk_usage() | ||
| 252 | .with_cmd(UpdateKind::OnlyIfNotSet) | ||
| 253 | .without_tasks() | ||
| 254 | } | ||
| 255 | |||
| 256 | fn matches_external_identity( | ||
| 257 | actual_start_time_ms: u64, | ||
| 258 | expected_start_time_ms: Option<u64>, | ||
| 259 | ) -> bool { | ||
| 260 | expected_start_time_ms.is_none_or(|expected| { | ||
| 261 | actual_start_time_ms.abs_diff(expected) <= EXTERNAL_PROCESS_START_TOLERANCE_MS | ||
| 262 | }) | ||
| 263 | } | ||
| 264 | |||
| 265 | fn select_tracked_pids(rows: &[(u32, u32, u64)], roots: &HashSet<u32>) -> HashSet<u32> { | ||
| 266 | let mut children_by_parent = HashMap::<u32, Vec<u32>>::new(); | ||
| 267 | for (pid, ppid, _) in rows { | ||
| 268 | children_by_parent.entry(*ppid).or_default().push(*pid); | ||
| 269 | } | ||
| 270 | |||
| 271 | let known_pids = rows.iter().map(|(pid, _, _)| *pid).collect::<HashSet<_>>(); | ||
| 272 | let mut tracked = HashSet::new(); | ||
| 273 | let mut queue = roots | ||
| 274 | .iter() | ||
| 275 | .copied() | ||
| 276 | .filter(|pid| known_pids.contains(pid)) | ||
| 277 | .collect::<VecDeque<_>>(); | ||
| 278 | |||
| 279 | while let Some(pid) = queue.pop_front() { | ||
| 280 | if !tracked.insert(pid) { | ||
| 281 | continue; | ||
| 282 | } | ||
| 283 | if let Some(children) = children_by_parent.get(&pid) { | ||
| 284 | queue.extend(children.iter().copied()); | ||
| 285 | } | ||
| 286 | } | ||
| 287 | |||
| 288 | tracked | ||
| 289 | } | ||
| 290 | |||
| 291 | fn io_semantics() -> IoSemantics { | ||
| 292 | if cfg!(target_os = "windows") { | ||
| 293 | IoSemantics::AllIo | ||
| 294 | } else { | ||
| 295 | IoSemantics::Storage | ||
| 296 | } | ||
| 297 | } | ||
| 298 | |||
| 299 | fn unix_time_ms() -> u64 { | ||
| 300 | SystemTime::now() | ||
| 301 | .duration_since(UNIX_EPOCH) | ||
| 302 | .unwrap_or_default() | ||
| 303 | .as_millis() as u64 | ||
| 304 | } | ||
| 305 | |||
| 306 | fn clamp_sample_interval(sample_interval_ms: u64) -> Duration { | ||
| 307 | Duration::from_millis(sample_interval_ms.clamp(MIN_SAMPLE_INTERVAL_MS, MAX_SAMPLE_INTERVAL_MS)) | ||
| 308 | } | ||
| 309 | |||
| 310 | fn spawn_input_reader() -> Receiver<Input> { | ||
| 311 | let (sender, receiver) = mpsc::channel(); | ||
| 312 | thread::spawn(move || { | ||
| 313 | let stdin = io::stdin(); | ||
| 314 | for line in stdin.lock().lines() { | ||
| 315 | let line = match line { | ||
| 316 | Ok(line) => line, | ||
| 317 | Err(error) => { | ||
| 318 | let _ = sender.send(Input::Invalid(format!( | ||
| 319 | "failed reading command stream: {error}" | ||
| 320 | ))); | ||
| 321 | return; | ||
| 322 | } | ||
| 323 | }; | ||
| 324 | if line.trim().is_empty() { | ||
| 325 | continue; | ||
| 326 | } | ||
| 327 | match serde_json::from_str::<Command>(&line) { | ||
| 328 | Ok(command) => { | ||
| 329 | if sender.send(Input::Command(command)).is_err() { | ||
| 330 | return; | ||
| 331 | } | ||
| 332 | } | ||
| 333 | Err(error) => { | ||
| 334 | if sender | ||
| 335 | .send(Input::Invalid(format!("invalid command: {error}"))) | ||
| 336 | .is_err() | ||
| 337 | { | ||
| 338 | return; | ||
| 339 | } | ||
| 340 | } | ||
| 341 | } | ||
| 342 | } | ||
| 343 | }); | ||
| 344 | receiver | ||
| 345 | } | ||
| 346 | |||
| 347 | fn write_event<T: Serialize>(writer: &mut impl Write, event: &T) -> io::Result<()> { | ||
| 348 | serde_json::to_writer(&mut *writer, event)?; | ||
| 349 | writer.write_all(b"\n")?; | ||
| 350 | writer.flush() | ||
| 351 | } | ||
| 352 | |||
| 353 | fn write_error( | ||
| 354 | writer: &mut impl Write, | ||
| 355 | code: &'static str, | ||
| 356 | message: impl Into<String>, | ||
| 357 | recoverable: bool, | ||
| 358 | ) -> io::Result<()> { | ||
| 359 | write_event( | ||
| 360 | writer, | ||
| 361 | &ErrorEvent { | ||
| 362 | version: PROTOCOL_VERSION, | ||
| 363 | event_type: "error", | ||
| 364 | code, | ||
| 365 | message: message.into(), | ||
| 366 | recoverable, | ||
| 367 | }, | ||
| 368 | ) | ||
| 369 | } | ||
| 370 | |||
| 371 | fn main() -> io::Result<()> { | ||
| 372 | let mut writer = BufWriter::new(io::stdout().lock()); | ||
| 373 | write_event( | ||
| 374 | &mut writer, | ||
| 375 | &HelloEvent { | ||
| 376 | version: PROTOCOL_VERSION, | ||
| 377 | event_type: "hello", | ||
| 378 | sidecar_version: env!("CARGO_PKG_VERSION"), | ||
| 379 | sidecar_pid: std::process::id(), | ||
| 380 | platform: std::env::consts::OS, | ||
| 381 | arch: std::env::consts::ARCH, | ||
| 382 | capabilities: Capabilities { | ||
| 383 | cumulative_cpu_time: true, | ||
| 384 | current_cpu_percent: true, | ||
| 385 | resident_memory: true, | ||
| 386 | virtual_memory: true, | ||
| 387 | io_bytes: true, | ||
| 388 | process_start_time: true, | ||
| 389 | process_tree: true, | ||
| 390 | }, | ||
| 391 | }, | ||
| 392 | )?; | ||
| 393 | |||
| 394 | let receiver = spawn_input_reader(); | ||
| 395 | let mut collector = Collector::new(); | ||
| 396 | let mut config: Option<CollectorConfig> = None; | ||
| 397 | let mut next_sample_at: Option<Instant> = None; | ||
| 398 | |||
| 399 | loop { | ||
| 400 | let timeout = next_sample_at | ||
| 401 | .map(|deadline| deadline.saturating_duration_since(Instant::now())) | ||
| 402 | .unwrap_or(Duration::from_secs(60)); | ||
| 403 | |||
| 404 | match receiver.recv_timeout(timeout) { | ||
| 405 | Ok(Input::Invalid(message)) => { | ||
| 406 | write_error(&mut writer, "invalid-command", message, true)?; | ||
| 407 | } | ||
| 408 | Ok(Input::Command(command)) => { | ||
| 409 | if command.version() != PROTOCOL_VERSION { | ||
| 410 | write_error( | ||
| 411 | &mut writer, | ||
| 412 | "protocol-mismatch", | ||
| 413 | format!( | ||
| 414 | "unsupported protocol version {}; expected {PROTOCOL_VERSION}", | ||
| 415 | command.version() | ||
| 416 | ), | ||
| 417 | false, | ||
| 418 | )?; | ||
| 419 | continue; | ||
| 420 | } | ||
| 421 | |||
| 422 | match command { | ||
| 423 | Command::Configure { | ||
| 424 | root_pid, | ||
| 425 | sample_interval_ms, | ||
| 426 | external_processes, | ||
| 427 | .. | ||
| 428 | } => { | ||
| 429 | let sample_interval = clamp_sample_interval(sample_interval_ms); | ||
| 430 | config = Some(CollectorConfig { | ||
| 431 | root_pid, | ||
| 432 | sample_interval, | ||
| 433 | external_processes: external_processes | ||
| 434 | .into_iter() | ||
| 435 | .map(|process| (process.pid, process.start_time_ms)) | ||
| 436 | .collect(), | ||
| 437 | }); | ||
| 438 | next_sample_at = Some(Instant::now()); | ||
| 439 | } | ||
| 440 | Command::SetExternalProcesses { processes, .. } => { | ||
| 441 | if let Some(current) = config.as_mut() { | ||
| 442 | current.external_processes = processes | ||
| 443 | .into_iter() | ||
| 444 | .map(|process| (process.pid, process.start_time_ms)) | ||
| 445 | .collect(); | ||
| 446 | } else { | ||
| 447 | write_error( | ||
| 448 | &mut writer, | ||
| 449 | "not-configured", | ||
| 450 | "configure must be sent before external processes", | ||
| 451 | true, | ||
| 452 | )?; | ||
| 453 | } | ||
| 454 | } | ||
| 455 | Command::SampleNow { request_id, .. } => { | ||
| 456 | if let Some(current) = config.as_ref() { | ||
| 457 | let event = collector.sample(current, Some(request_id)); | ||
| 458 | write_event(&mut writer, &event)?; | ||
| 459 | next_sample_at = Some(Instant::now() + current.sample_interval); | ||
| 460 | } else { | ||
| 461 | write_error( | ||
| 462 | &mut writer, | ||
| 463 | "not-configured", | ||
| 464 | "configure must be sent before sampling", | ||
| 465 | true, | ||
| 466 | )?; | ||
| 467 | } | ||
| 468 | } | ||
| 469 | Command::Shutdown { .. } => return Ok(()), | ||
| 470 | } | ||
| 471 | } | ||
| 472 | Err(RecvTimeoutError::Timeout) => { | ||
| 473 | if let Some(current) = config.as_ref() { | ||
| 474 | let event = collector.sample(current, None); | ||
| 475 | write_event(&mut writer, &event)?; | ||
| 476 | next_sample_at = Some(Instant::now() + current.sample_interval); | ||
| 477 | } | ||
| 478 | } | ||
| 479 | Err(RecvTimeoutError::Disconnected) => return Ok(()), | ||
| 480 | } | ||
| 481 | } | ||
| 482 | } | ||
| 483 | |||
| 484 | #[cfg(test)] | ||
| 485 | mod tests { | ||
| 486 | use super::*; | ||
| 487 | |||
| 488 | #[test] | ||
| 489 | fn selects_roots_and_all_descendants() { | ||
| 490 | let rows = vec![ | ||
| 491 | (10, 1, 1_000), | ||
| 492 | (11, 10, 1_100), | ||
| 493 | (12, 11, 1_200), | ||
| 494 | (20, 1, 2_000), | ||
| 495 | (21, 20, 2_100), | ||
| 496 | (30, 99, 3_000), | ||
| 497 | ]; | ||
| 498 | let tracked = select_tracked_pids(&rows, &HashSet::from([10, 20])); | ||
| 499 | |||
| 500 | assert_eq!(tracked, HashSet::from([10, 11, 12, 20, 21])); | ||
| 501 | } | ||
| 502 | |||
| 503 | #[test] | ||
| 504 | fn ignores_missing_roots() { | ||
| 505 | let rows = vec![(10, 1, 1_000), (11, 10, 1_100)]; | ||
| 506 | let tracked = select_tracked_pids(&rows, &HashSet::from([99])); | ||
| 507 | |||
| 508 | assert!(tracked.is_empty()); | ||
| 509 | } | ||
| 510 | |||
| 511 | #[test] | ||
| 512 | fn validates_external_process_start_identity() { | ||
| 513 | assert!(matches_external_identity(10_000, None)); | ||
| 514 | assert!(matches_external_identity(10_000, Some(11_999))); | ||
| 515 | assert!(!matches_external_identity(10_000, Some(12_001))); | ||
| 516 | } | ||
| 517 | |||
| 518 | #[test] | ||
| 519 | fn decodes_protocol_commands() { | ||
| 520 | let configure = serde_json::from_str::<Command>( | ||
| 521 | r#"{"version":1,"type":"configure","rootPid":42,"sampleIntervalMs":1000,"externalProcesses":[{"pid":7}]}"#, | ||
| 522 | ) | ||
| 523 | .expect("configure command"); | ||
| 524 | |||
| 525 | match configure { | ||
| 526 | Command::Configure { | ||
| 527 | root_pid, | ||
| 528 | sample_interval_ms, | ||
| 529 | external_processes, | ||
| 530 | .. | ||
| 531 | } => { | ||
| 532 | assert_eq!(root_pid, 42); | ||
| 533 | assert_eq!(sample_interval_ms, 1_000); | ||
| 534 | assert_eq!(external_processes[0].pid, 7); | ||
| 535 | assert_eq!(external_processes[0].start_time_ms, None); | ||
| 536 | } | ||
| 537 | _ => panic!("unexpected command"), | ||
| 538 | } | ||
| 539 | } | ||
| 540 | |||
| 541 | #[test] | ||
| 542 | fn clamps_sample_interval() { | ||
| 543 | assert_eq!(clamp_sample_interval(1), Duration::from_millis(250)); | ||
| 544 | assert_eq!( | ||
| 545 | clamp_sample_interval(100_000), | ||
| 546 | Duration::from_millis(60_000) | ||
| 547 | ); | ||
| 548 | } | ||
| 549 | |||
| 550 | #[test] | ||
| 551 | fn refreshes_commands_without_enumerating_linux_tasks() { | ||
| 552 | let refresh_kind = process_refresh_kind(); | ||
| 553 | |||
| 554 | assert_eq!(refresh_kind.cmd(), UpdateKind::OnlyIfNotSet); | ||
| 555 | assert!(!refresh_kind.tasks()); | ||
| 556 | assert!(refresh_kind.cpu()); | ||
| 557 | assert!(refresh_kind.memory()); | ||
| 558 | assert!(refresh_kind.disk_usage()); | ||
| 559 | } | ||
| 560 | } | ||
| @@ -16,11 +16,13 @@ · 15 unmodified lines | |||
| 16 | "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", | 16 | "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", |
| 17 | "build:marketing": "vp run --filter @t3tools/marketing build", | 17 | "build:marketing": "vp run --filter @t3tools/marketing build", |
| 18 | "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build", | 18 | "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build", |
| 19 | "build:resource-monitor": "cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml", | ||
| 19 | "typecheck": "vp run -r --concurrency-limit 2 typecheck", | 20 | "typecheck": "vp run -r --concurrency-limit 2 typecheck", |
| 20 | "tc": "vp run -r --concurrency-limit 2 typecheck", | 21 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 21 | "lint": "vp lint --report-unused-disable-directives", | 22 | "lint": "vp lint --report-unused-disable-directives", |
| 22 | "lint:mobile": "node scripts/mobile-native-static-check.ts", | 23 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 23 | "test": "vp run -r test", | 24 | "test": "vp run -r test", |
| 25 | "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml", | ||
| 24 | "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test", | 26 | "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test", |
| 25 | "fmt": "vp fmt", | 27 | "fmt": "vp fmt", |
| 26 | "fmt:check": "vp fmt --check", | 28 | "fmt:check": "vp fmt --check", |
| @@ -171,6 +171,18 @@ export interface WsRpcClient { · 170 unmodified lines | |||
| 171 | readonly getProcessResourceHistory: RpcUnaryMethod< | 171 | readonly getProcessResourceHistory: RpcUnaryMethod< |
| 172 | typeof WS_METHODS.serverGetProcessResourceHistory | 172 | typeof WS_METHODS.serverGetProcessResourceHistory |
| 173 | >; | 173 | >; |
| 174 | readonly getResourceTelemetry: RpcUnaryNoArgMethod< | ||
| 175 | typeof WS_METHODS.serverGetResourceTelemetry | ||
| 176 | >; | ||
| 177 | readonly getResourceTelemetryHistory: RpcUnaryMethod< | ||
| 178 | typeof WS_METHODS.serverGetResourceTelemetryHistory | ||
| 179 | >; | ||
| 180 | readonly retryResourceTelemetry: RpcUnaryNoArgMethod< | ||
| 181 | typeof WS_METHODS.serverRetryResourceTelemetry | ||
| 182 | >; | ||
| 183 | readonly subscribeResourceTelemetry: RpcStreamMethod< | ||
| 184 | typeof WS_METHODS.subscribeResourceTelemetry | ||
| 185 | >; | ||
| 174 | readonly signalProcess: RpcUnaryMethod<typeof WS_METHODS.serverSignalProcess>; | 186 | readonly signalProcess: RpcUnaryMethod<typeof WS_METHODS.serverSignalProcess>; |
| 175 | readonly reportClientActivity: RpcUnaryMethod<typeof WS_METHODS.serverReportClientActivity>; | 187 | readonly reportClientActivity: RpcUnaryMethod<typeof WS_METHODS.serverReportClientActivity>; |
| 176 | readonly reportHostPowerState: RpcUnaryMethod<typeof WS_METHODS.serverReportHostPowerState>; | 188 | readonly reportHostPowerState: RpcUnaryMethod<typeof WS_METHODS.serverReportHostPowerState>; |
| @@ -395,6 +407,18 @@ export function createWsRpcClient( · 218 unmodified lines | |||
| 395 | transport.request((client) => client[WS_METHODS.serverGetProcessDiagnostics]({})), | 407 | transport.request((client) => client[WS_METHODS.serverGetProcessDiagnostics]({})), |
| 396 | getProcessResourceHistory: (input) => | 408 | getProcessResourceHistory: (input) => |
| 397 | transport.request((client) => client[WS_METHODS.serverGetProcessResourceHistory](input)), | 409 | transport.request((client) => client[WS_METHODS.serverGetProcessResourceHistory](input)), |
| 410 | getResourceTelemetry: () => | ||
| 411 | transport.request((client) => client[WS_METHODS.serverGetResourceTelemetry]({})), | ||
| 412 | getResourceTelemetryHistory: (input) => | ||
| 413 | transport.request((client) => client[WS_METHODS.serverGetResourceTelemetryHistory](input)), | ||
| 414 | retryResourceTelemetry: () => | ||
| 415 | transport.request((client) => client[WS_METHODS.serverRetryResourceTelemetry]({})), | ||
| 416 | subscribeResourceTelemetry: (listener, options) => | ||
| 417 | transport.subscribe( | ||
| 418 | (client) => client[WS_METHODS.subscribeResourceTelemetry]({}), | ||
| 419 | listener, | ||
| 420 | subscriptionOptions(options, WS_METHODS.subscribeResourceTelemetry), | ||
| 421 | ), | ||
| 398 | signalProcess: (input) => | 422 | signalProcess: (input) => |
| 399 | transport.request((client) => client[WS_METHODS.serverSignalProcess](input)), | 423 | transport.request((client) => client[WS_METHODS.serverSignalProcess](input)), |
| 400 | reportClientActivity: (input) => | 424 | reportClientActivity: (input) => |
| @@ -1,6 +1,6 @@ | |||
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | ||
| 3 | import { PortSchema } from "./baseSchemas.ts"; | 3 | import { PortSchema, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | 4 | ||
| 5 | export const DesktopBackendBootstrap = Schema.Struct({ | 5 | export const DesktopBackendBootstrap = Schema.Struct({ |
| 6 | mode: Schema.Literal("desktop"), | 6 | mode: Schema.Literal("desktop"), |
| @@ -13,6 +13,8 @@ export const DesktopBackendBootstrap = Schema.Struct({ · 6 unmodified lines | |||
| 13 | tailscaleServePort: PortSchema, | 13 | tailscaleServePort: PortSchema, |
| 14 | otlpTracesUrl: Schema.optional(Schema.String), | 14 | otlpTracesUrl: Schema.optional(Schema.String), |
| 15 | otlpMetricsUrl: Schema.optional(Schema.String), | 15 | otlpMetricsUrl: Schema.optional(Schema.String), |
| 16 | desktopTelemetryFd: Schema.optionalKey(PositiveInt), | ||
| 17 | resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString), | ||
| 16 | }); | 18 | }); |
| 17 | 19 | ||
| 18 | export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type; | 20 | export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type; |
| @@ -26,4 +26,5 @@ export * from "./assets.ts"; · 25 unmodified lines | |||
| 26 | export * from "./review.ts"; | 26 | export * from "./review.ts"; |
| 27 | export * from "./preview.ts"; | 27 | export * from "./preview.ts"; |
| 28 | export * from "./previewAutomation.ts"; | 28 | export * from "./previewAutomation.ts"; |
| 29 | export * from "./resourceTelemetry.ts"; | ||
| 29 | export * from "./rpc.ts"; | 30 | export * from "./rpc.ts"; |
| @@ -32,6 +32,12 @@ import type { · 31 unmodified lines | |||
| 32 | ProjectWriteFileResult, | 32 | ProjectWriteFileResult, |
| 33 | } from "./project.ts"; | 33 | } from "./project.ts"; |
| 34 | import type { ProviderInstanceId } from "./providerInstance.ts"; | 34 | import type { ProviderInstanceId } from "./providerInstance.ts"; |
| 35 | import type { | ||
| 36 | ResourceTelemetryHistory, | ||
| 37 | ResourceTelemetryHistoryInput, | ||
| 38 | ResourceTelemetryRetryResult, | ||
| 39 | ResourceTelemetrySnapshot, | ||
| 40 | } from "./resourceTelemetry.ts"; | ||
| 35 | import type { | 41 | import type { |
| 36 | ServerConfig, | 42 | ServerConfig, |
| 37 | ServerProcessDiagnosticsResult, | 43 | ServerProcessDiagnosticsResult, |
| @@ -1078,6 +1084,11 @@ export interface LocalApi { · 1040 unmodified lines | |||
| 1078 | getProcessResourceHistory: ( | 1084 | getProcessResourceHistory: ( |
| 1079 | input: ServerProcessResourceHistoryInput, | 1085 | input: ServerProcessResourceHistoryInput, |
| 1080 | ) => Promise<ServerProcessResourceHistoryResult>; | 1086 | ) => Promise<ServerProcessResourceHistoryResult>; |
| 1087 | getResourceTelemetry: () => Promise<ResourceTelemetrySnapshot>; | ||
| 1088 | getResourceTelemetryHistory: ( | ||
| 1089 | input: ResourceTelemetryHistoryInput, | ||
| 1090 | ) => Promise<ResourceTelemetryHistory>; | ||
| 1091 | retryResourceTelemetry: () => Promise<ResourceTelemetryRetryResult>; | ||
| 1081 | signalProcess: (input: ServerSignalProcessInput) => Promise<ServerSignalProcessResult>; | 1092 | signalProcess: (input: ServerSignalProcessInput) => Promise<ServerSignalProcessResult>; |
| 1082 | }; | 1093 | }; |
| 1083 | } | 1094 | } |
| @@ -0,0 +1,365 @@ | |||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | |||
| 3 | import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 4 | import { HostPowerSnapshot } from "./background.ts"; | ||
| 5 | |||
| 6 | export const RESOURCE_MONITOR_PROTOCOL_VERSION = 1 as const; | ||
| 7 | |||
| 8 | export const ResourceTelemetryIoSemantics = Schema.Literals([ | ||
| 9 | "storage", | ||
| 10 | "logical", | ||
| 11 | "all-io", | ||
| 12 | "unavailable", | ||
| 13 | ]); | ||
| 14 | export type ResourceTelemetryIoSemantics = typeof ResourceTelemetryIoSemantics.Type; | ||
| 15 | |||
| 16 | export const ResourceTelemetryProcessCategory = Schema.Literals([ | ||
| 17 | "server", | ||
| 18 | "server-child", | ||
| 19 | "provider-root", | ||
| 20 | "terminal-root", | ||
| 21 | "electron-main", | ||
| 22 | "electron-renderer", | ||
| 23 | "electron-gpu", | ||
| 24 | "electron-utility", | ||
| 25 | "resource-monitor", | ||
| 26 | "unknown-t3", | ||
| 27 | ]); | ||
| 28 | export type ResourceTelemetryProcessCategory = typeof ResourceTelemetryProcessCategory.Type; | ||
| 29 | |||
| 30 | export const ResourceTelemetrySourceStatus = Schema.Literals([ | ||
| 31 | "starting", | ||
| 32 | "healthy", | ||
| 33 | "degraded", | ||
| 34 | "unavailable", | ||
| 35 | "stopped", | ||
| 36 | ]); | ||
| 37 | export type ResourceTelemetrySourceStatus = typeof ResourceTelemetrySourceStatus.Type; | ||
| 38 | |||
| 39 | export const ResourceTelemetryProcessIdentity = Schema.Struct({ | ||
| 40 | pid: PositiveInt, | ||
| 41 | startTimeMs: NonNegativeInt, | ||
| 42 | }); | ||
| 43 | export type ResourceTelemetryProcessIdentity = typeof ResourceTelemetryProcessIdentity.Type; | ||
| 44 | |||
| 45 | export const ResourceMonitorExternalProcess = Schema.Struct({ | ||
| 46 | pid: PositiveInt, | ||
| 47 | startTimeMs: Schema.optionalKey(NonNegativeInt), | ||
| 48 | }); | ||
| 49 | export type ResourceMonitorExternalProcess = typeof ResourceMonitorExternalProcess.Type; | ||
| 50 | |||
| 51 | export const ResourceMonitorCapabilities = Schema.Struct({ | ||
| 52 | cumulativeCpuTime: Schema.Boolean, | ||
| 53 | currentCpuPercent: Schema.Boolean, | ||
| 54 | residentMemory: Schema.Boolean, | ||
| 55 | virtualMemory: Schema.Boolean, | ||
| 56 | ioBytes: Schema.Boolean, | ||
| 57 | processStartTime: Schema.Boolean, | ||
| 58 | processTree: Schema.Boolean, | ||
| 59 | }); | ||
| 60 | export type ResourceMonitorCapabilities = typeof ResourceMonitorCapabilities.Type; | ||
| 61 | |||
| 62 | export const ResourceMonitorProcessSample = Schema.Struct({ | ||
| 63 | pid: PositiveInt, | ||
| 64 | ppid: NonNegativeInt, | ||
| 65 | startTimeMs: NonNegativeInt, | ||
| 66 | runTimeMs: NonNegativeInt, | ||
| 67 | name: Schema.String, | ||
| 68 | command: Schema.String, | ||
| 69 | status: Schema.String, | ||
| 70 | cpuPercent: Schema.Number, | ||
| 71 | cpuTimeMs: NonNegativeInt, | ||
| 72 | residentBytes: NonNegativeInt, | ||
| 73 | virtualBytes: NonNegativeInt, | ||
| 74 | ioReadBytes: NonNegativeInt, | ||
| 75 | ioWriteBytes: NonNegativeInt, | ||
| 76 | ioSemantics: Schema.Literals(["storage", "all-io"]), | ||
| 77 | }); | ||
| 78 | export type ResourceMonitorProcessSample = typeof ResourceMonitorProcessSample.Type; | ||
| 79 | |||
| 80 | export const ResourceMonitorConfigureCommand = Schema.Struct({ | ||
| 81 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | ||
| 82 | type: Schema.Literal("configure"), | ||
| 83 | rootPid: PositiveInt, | ||
| 84 | sampleIntervalMs: NonNegativeInt, | ||
| 85 | externalProcesses: Schema.Array(ResourceMonitorExternalProcess), | ||
| 86 | }); | ||
| 87 | export type ResourceMonitorConfigureCommand = typeof ResourceMonitorConfigureCommand.Type; | ||
| 88 | |||
| 89 | export const ResourceMonitorSetExternalProcessesCommand = Schema.Struct({ | ||
| 90 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | ||
| 91 | type: Schema.Literal("setExternalProcesses"), | ||
| 92 | processes: Schema.Array(ResourceMonitorExternalProcess), | ||
| 93 | }); | ||
| 94 | export type ResourceMonitorSetExternalProcessesCommand = | ||
| 95 | typeof ResourceMonitorSetExternalProcessesCommand.Type; | ||
| 96 | |||
| 97 | export const ResourceMonitorSampleNowCommand = Schema.Struct({ | ||
| 98 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | ||
| 99 | type: Schema.Literal("sampleNow"), | ||
| 100 | requestId: TrimmedNonEmptyString, | ||
| 101 | }); | ||
| 102 | export type ResourceMonitorSampleNowCommand = typeof ResourceMonitorSampleNowCommand.Type; | ||
| 103 | |||
| 104 | export const ResourceMonitorShutdownCommand = Schema.Struct({ | ||
| 105 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | ||
| 106 | type: Schema.Literal("shutdown"), | ||
| 107 | }); | ||
| 108 | export type ResourceMonitorShutdownCommand = typeof ResourceMonitorShutdownCommand.Type; | ||
| 109 | |||
| 110 | export const ResourceMonitorCommand = Schema.Union([ | ||
| 111 | ResourceMonitorConfigureCommand, | ||
| 112 | ResourceMonitorSetExternalProcessesCommand, | ||
| 113 | ResourceMonitorSampleNowCommand, | ||
| 114 | ResourceMonitorShutdownCommand, | ||
| 115 | ]); | ||
| 116 | export type ResourceMonitorCommand = typeof ResourceMonitorCommand.Type; | ||
| 117 | |||
| 118 | export const ResourceMonitorHelloEvent = Schema.Struct({ | ||
| 119 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | ||
| 120 | type: Schema.Literal("hello"), | ||
| 121 | sidecarVersion: TrimmedNonEmptyString, | ||
| 122 | sidecarPid: PositiveInt, | ||
| 123 | platform: TrimmedNonEmptyString, | ||
| 124 | arch: TrimmedNonEmptyString, | ||
| 125 | capabilities: ResourceMonitorCapabilities, | ||
| 126 | }); | ||
| 127 | export type ResourceMonitorHelloEvent = typeof ResourceMonitorHelloEvent.Type; | ||
| 128 | |||
| 129 | export const ResourceMonitorSnapshotEvent = Schema.Struct({ | ||
| 130 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | ||
| 131 | type: Schema.Literal("snapshot"), | ||
| 132 | sequence: NonNegativeInt, | ||
| 133 | sampledAtUnixMs: NonNegativeInt, | ||
| 134 | collectionDurationMicros: NonNegativeInt, | ||
| 135 | scannedProcessCount: NonNegativeInt, | ||
| 136 | retainedProcessCount: NonNegativeInt, | ||
| 137 | inaccessibleProcessCount: NonNegativeInt, | ||
| 138 | requestId: Schema.optionalKey(TrimmedNonEmptyString), | ||
| 139 | processes: Schema.Array(ResourceMonitorProcessSample), | ||
| 140 | }); | ||
| 141 | export type ResourceMonitorSnapshotEvent = typeof ResourceMonitorSnapshotEvent.Type; | ||
| 142 | |||
| 143 | export const ResourceMonitorErrorEvent = Schema.Struct({ | ||
| 144 | version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), | ||
| 145 | type: Schema.Literal("error"), | ||
| 146 | code: TrimmedNonEmptyString, | ||
| 147 | message: TrimmedNonEmptyString, | ||
| 148 | recoverable: Schema.Boolean, | ||
| 149 | }); | ||
| 150 | export type ResourceMonitorErrorEvent = typeof ResourceMonitorErrorEvent.Type; | ||
| 151 | |||
| 152 | export const ResourceMonitorEvent = Schema.Union([ | ||
| 153 | ResourceMonitorHelloEvent, | ||
| 154 | ResourceMonitorSnapshotEvent, | ||
| 155 | ResourceMonitorErrorEvent, | ||
| 156 | ]); | ||
| 157 | export type ResourceMonitorEvent = typeof ResourceMonitorEvent.Type; | ||
| 158 | |||
| 159 | export const DesktopElectronProcessType = Schema.Literals([ | ||
| 160 | "Browser", | ||
| 161 | "Tab", | ||
| 162 | "Utility", | ||
| 163 | "Zygote", | ||
| 164 | "Sandbox helper", | ||
| 165 | "GPU", | ||
| 166 | "Pepper Plugin", | ||
| 167 | "Pepper Plugin Broker", | ||
| 168 | "Unknown", | ||
| 169 | ]); | ||
| 170 | export type DesktopElectronProcessType = typeof DesktopElectronProcessType.Type; | ||
| 171 | |||
| 172 | export const DesktopElectronProcessMetric = Schema.Struct({ | ||
| 173 | pid: PositiveInt, | ||
| 174 | creationTimeMs: NonNegativeInt, | ||
| 175 | type: DesktopElectronProcessType, | ||
| 176 | name: Schema.optionalKey(Schema.String), | ||
| 177 | serviceName: Schema.optionalKey(Schema.String), | ||
| 178 | cpuPercent: Schema.Number, | ||
| 179 | cumulativeCpuSeconds: Schema.optionalKey(Schema.Number), | ||
| 180 | idleWakeupsPerSecond: Schema.Number, | ||
| 181 | workingSetBytes: NonNegativeInt, | ||
| 182 | peakWorkingSetBytes: NonNegativeInt, | ||
| 183 | }); | ||
| 184 | export type DesktopElectronProcessMetric = typeof DesktopElectronProcessMetric.Type; | ||
| 185 | |||
| 186 | export const DesktopHostTelemetrySnapshot = Schema.Struct({ | ||
| 187 | version: Schema.Literal(1), | ||
| 188 | type: Schema.Literal("desktopTelemetry"), | ||
| 189 | sequence: NonNegativeInt, | ||
| 190 | sampledAtUnixMs: NonNegativeInt, | ||
| 191 | power: HostPowerSnapshot, | ||
| 192 | speedLimitPercent: Schema.Option(Schema.Number), | ||
| 193 | electronProcesses: Schema.Array(DesktopElectronProcessMetric), | ||
| 194 | }); | ||
| 195 | export type DesktopHostTelemetrySnapshot = typeof DesktopHostTelemetrySnapshot.Type; | ||
| 196 | |||
| 197 | export const DesktopHostTelemetryHello = Schema.Struct({ | ||
| 198 | version: Schema.Literal(1), | ||
| 199 | type: Schema.Literal("desktopTelemetryHello"), | ||
| 200 | electronPid: PositiveInt, | ||
| 201 | }); | ||
| 202 | export type DesktopHostTelemetryHello = typeof DesktopHostTelemetryHello.Type; | ||
| 203 | |||
| 204 | export const DesktopHostTelemetryMessage = Schema.Union([ | ||
| 205 | DesktopHostTelemetryHello, | ||
| 206 | DesktopHostTelemetrySnapshot, | ||
| 207 | ]); | ||
| 208 | export type DesktopHostTelemetryMessage = typeof DesktopHostTelemetryMessage.Type; | ||
| 209 | |||
| 210 | export const ResourceTelemetryProcess = Schema.Struct({ | ||
| 211 | identity: ResourceTelemetryProcessIdentity, | ||
| 212 | ppid: NonNegativeInt, | ||
| 213 | childPids: Schema.Array(PositiveInt), | ||
| 214 | depth: NonNegativeInt, | ||
| 215 | name: Schema.String, | ||
| 216 | command: Schema.String, | ||
| 217 | status: Schema.String, | ||
| 218 | category: ResourceTelemetryProcessCategory, | ||
| 219 | electronType: Schema.optionalKey(DesktopElectronProcessType), | ||
| 220 | electronServiceName: Schema.optionalKey(Schema.String), | ||
| 221 | cpuPercent: Schema.Number, | ||
| 222 | cpuTimeMs: NonNegativeInt, | ||
| 223 | residentBytes: NonNegativeInt, | ||
| 224 | peakResidentBytes: NonNegativeInt, | ||
| 225 | virtualBytes: NonNegativeInt, | ||
| 226 | ioReadBytes: NonNegativeInt, | ||
| 227 | ioWriteBytes: NonNegativeInt, | ||
| 228 | ioReadBytesPerSecond: Schema.Number, | ||
| 229 | ioWriteBytesPerSecond: Schema.Number, | ||
| 230 | ioSemantics: ResourceTelemetryIoSemantics, | ||
| 231 | idleWakeupsPerSecond: Schema.optionalKey(Schema.Number), | ||
| 232 | runTimeMs: NonNegativeInt, | ||
| 233 | firstSeenAt: Schema.DateTimeUtc, | ||
| 234 | lastSeenAt: Schema.DateTimeUtc, | ||
| 235 | }); | ||
| 236 | export type ResourceTelemetryProcess = typeof ResourceTelemetryProcess.Type; | ||
| 237 | |||
| 238 | export const ResourceTelemetryAggregate = Schema.Struct({ | ||
| 239 | processCount: NonNegativeInt, | ||
| 240 | currentCpuPercent: Schema.Number, | ||
| 241 | cpuTimeMs: NonNegativeInt, | ||
| 242 | currentRssBytes: NonNegativeInt, | ||
| 243 | peakRssBytes: NonNegativeInt, | ||
| 244 | ioReadBytes: NonNegativeInt, | ||
| 245 | ioWriteBytes: NonNegativeInt, | ||
| 246 | ioReadBytesPerSecond: Schema.Number, | ||
| 247 | ioWriteBytesPerSecond: Schema.Number, | ||
| 248 | processStarts: NonNegativeInt, | ||
| 249 | processExits: NonNegativeInt, | ||
| 250 | }); | ||
| 251 | export type ResourceTelemetryAggregate = typeof ResourceTelemetryAggregate.Type; | ||
| 252 | |||
| 253 | export const ResourceTelemetryGroups = Schema.Struct({ | ||
| 254 | backend: ResourceTelemetryAggregate, | ||
| 255 | electron: ResourceTelemetryAggregate, | ||
| 256 | monitor: ResourceTelemetryAggregate, | ||
| 257 | allT3: ResourceTelemetryAggregate, | ||
| 258 | }); | ||
| 259 | export type ResourceTelemetryGroups = typeof ResourceTelemetryGroups.Type; | ||
| 260 | |||
| 261 | export const ResourceTelemetrySourceHealth = Schema.Struct({ | ||
| 262 | status: ResourceTelemetrySourceStatus, | ||
| 263 | lastSampleAt: Schema.Option(Schema.DateTimeUtc), | ||
| 264 | lastError: Schema.Option(TrimmedNonEmptyString), | ||
| 265 | }); | ||
| 266 | export type ResourceTelemetrySourceHealth = typeof ResourceTelemetrySourceHealth.Type; | ||
| 267 | |||
| 268 | export const ResourceTelemetryHealth = Schema.Struct({ | ||
| 269 | native: ResourceTelemetrySourceHealth, | ||
| 270 | desktop: ResourceTelemetrySourceHealth, | ||
| 271 | sidecarVersion: Schema.Option(TrimmedNonEmptyString), | ||
| 272 | sidecarPid: Schema.Option(PositiveInt), | ||
| 273 | restartCount: NonNegativeInt, | ||
| 274 | collectionDurationMicros: NonNegativeInt, | ||
| 275 | scannedProcessCount: NonNegativeInt, | ||
| 276 | retainedProcessCount: NonNegativeInt, | ||
| 277 | inaccessibleProcessCount: NonNegativeInt, | ||
| 278 | }); | ||
| 279 | export type ResourceTelemetryHealth = typeof ResourceTelemetryHealth.Type; | ||
| 280 | |||
| 281 | export const ResourceAttributionEntry = Schema.Struct({ | ||
| 282 | component: TrimmedNonEmptyString, | ||
| 283 | operation: TrimmedNonEmptyString, | ||
| 284 | logicalReadBytes: NonNegativeInt, | ||
| 285 | logicalWriteBytes: NonNegativeInt, | ||
| 286 | count: NonNegativeInt, | ||
| 287 | durationMs: NonNegativeInt, | ||
| 288 | }); | ||
| 289 | export type ResourceAttributionEntry = typeof ResourceAttributionEntry.Type; | ||
| 290 | |||
| 291 | export const ResourceAttributionSnapshot = Schema.Struct({ | ||
| 292 | readAt: Schema.DateTimeUtc, | ||
| 293 | entries: Schema.Array(ResourceAttributionEntry), | ||
| 294 | }); | ||
| 295 | export type ResourceAttributionSnapshot = typeof ResourceAttributionSnapshot.Type; | ||
| 296 | |||
| 297 | export const ResourceTelemetrySnapshot = Schema.Struct({ | ||
| 298 | readAt: Schema.DateTimeUtc, | ||
| 299 | sampleIntervalMs: NonNegativeInt, | ||
| 300 | processes: Schema.Array(ResourceTelemetryProcess), | ||
| 301 | groups: ResourceTelemetryGroups, | ||
| 302 | power: HostPowerSnapshot, | ||
| 303 | speedLimitPercent: Schema.Option(Schema.Number), | ||
| 304 | attribution: ResourceAttributionSnapshot, | ||
| 305 | health: ResourceTelemetryHealth, | ||
| 306 | }); | ||
| 307 | export type ResourceTelemetrySnapshot = typeof ResourceTelemetrySnapshot.Type; | ||
| 308 | |||
| 309 | export const ResourceTelemetryHistoryInput = Schema.Struct({ | ||
| 310 | windowMs: NonNegativeInt, | ||
| 311 | bucketMs: NonNegativeInt, | ||
| 312 | }); | ||
| 313 | export type ResourceTelemetryHistoryInput = typeof ResourceTelemetryHistoryInput.Type; | ||
| 314 | |||
| 315 | export const ResourceTelemetryHistoryBucket = Schema.Struct({ | ||
| 316 | startedAt: Schema.DateTimeUtc, | ||
| 317 | endedAt: Schema.DateTimeUtc, | ||
| 318 | avgCpuPercent: Schema.Number, | ||
| 319 | maxCpuPercent: Schema.Number, | ||
| 320 | maxRssBytes: NonNegativeInt, | ||
| 321 | ioReadBytes: NonNegativeInt, | ||
| 322 | ioWriteBytes: NonNegativeInt, | ||
| 323 | maxProcessCount: NonNegativeInt, | ||
| 324 | }); | ||
| 325 | export type ResourceTelemetryHistoryBucket = typeof ResourceTelemetryHistoryBucket.Type; | ||
| 326 | |||
| 327 | export const ResourceTelemetryProcessSummary = Schema.Struct({ | ||
| 328 | identity: ResourceTelemetryProcessIdentity, | ||
| 329 | ppid: NonNegativeInt, | ||
| 330 | depth: NonNegativeInt, | ||
| 331 | name: Schema.String, | ||
| 332 | command: Schema.String, | ||
| 333 | category: ResourceTelemetryProcessCategory, | ||
| 334 | firstSeenAt: Schema.DateTimeUtc, | ||
| 335 | lastSeenAt: Schema.DateTimeUtc, | ||
| 336 | currentCpuPercent: Schema.Number, | ||
| 337 | avgCpuPercent: Schema.Number, | ||
| 338 | maxCpuPercent: Schema.Number, | ||
| 339 | cpuTimeMs: NonNegativeInt, | ||
| 340 | currentRssBytes: NonNegativeInt, | ||
| 341 | peakRssBytes: NonNegativeInt, | ||
| 342 | ioReadBytes: NonNegativeInt, | ||
| 343 | ioWriteBytes: NonNegativeInt, | ||
| 344 | ioSemantics: ResourceTelemetryIoSemantics, | ||
| 345 | sampleCount: NonNegativeInt, | ||
| 346 | }); | ||
| 347 | export type ResourceTelemetryProcessSummary = typeof ResourceTelemetryProcessSummary.Type; | ||
| 348 | |||
| 349 | export const ResourceTelemetryHistory = Schema.Struct({ | ||
| 350 | readAt: Schema.DateTimeUtc, | ||
| 351 | windowMs: NonNegativeInt, | ||
| 352 | bucketMs: NonNegativeInt, | ||
| 353 | sampleIntervalMs: NonNegativeInt, | ||
| 354 | retainedSampleCount: NonNegativeInt, | ||
| 355 | buckets: Schema.Array(ResourceTelemetryHistoryBucket), | ||
| 356 | topProcesses: Schema.Array(ResourceTelemetryProcessSummary), | ||
| 357 | health: ResourceTelemetryHealth, | ||
| 358 | }); | ||
| 359 | export type ResourceTelemetryHistory = typeof ResourceTelemetryHistory.Type; | ||
| 360 | |||
| 361 | export const ResourceTelemetryRetryResult = Schema.Struct({ | ||
| 362 | accepted: Schema.Boolean, | ||
| 363 | snapshot: ResourceTelemetrySnapshot, | ||
| 364 | }); | ||
| 365 | export type ResourceTelemetryRetryResult = typeof ResourceTelemetryRetryResult.Type; | ||
| @@ -134,6 +134,12 @@ import { · 133 unmodified lines | |||
| 134 | ServerUpsertKeybindingInput, | 134 | ServerUpsertKeybindingInput, |
| 135 | ServerUpsertKeybindingResult, | 135 | ServerUpsertKeybindingResult, |
| 136 | } from "./server.ts"; | 136 | } from "./server.ts"; |
| 137 | import { | ||
| 138 | ResourceTelemetryHistory, | ||
| 139 | ResourceTelemetryHistoryInput, | ||
| 140 | ResourceTelemetryRetryResult, | ||
| 141 | ResourceTelemetrySnapshot, | ||
| 142 | } from "./resourceTelemetry.ts"; | ||
| 137 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; | 143 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; |
| 138 | import { | 144 | import { |
| 139 | SourceControlCloneRepositoryInput, | 145 | SourceControlCloneRepositoryInput, |
| @@ -215,6 +221,9 @@ export const WS_METHODS = { · 75 unmodified lines | |||
| 215 | serverGetTraceDiagnostics: "server.getTraceDiagnostics", | 221 | serverGetTraceDiagnostics: "server.getTraceDiagnostics", |
| 216 | serverGetProcessDiagnostics: "server.getProcessDiagnostics", | 222 | serverGetProcessDiagnostics: "server.getProcessDiagnostics", |
| 217 | serverGetProcessResourceHistory: "server.getProcessResourceHistory", | 223 | serverGetProcessResourceHistory: "server.getProcessResourceHistory", |
| 224 | serverGetResourceTelemetry: "server.getResourceTelemetry", | ||
| 225 | serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", | ||
| 226 | serverRetryResourceTelemetry: "server.retryResourceTelemetry", | ||
| 218 | serverSignalProcess: "server.signalProcess", | 227 | serverSignalProcess: "server.signalProcess", |
| 219 | serverReportClientActivity: "server.reportClientActivity", | 228 | serverReportClientActivity: "server.reportClientActivity", |
| 220 | serverReportHostPowerState: "server.reportHostPowerState", | 229 | serverReportHostPowerState: "server.reportHostPowerState", |
| @@ -239,6 +248,7 @@ export const WS_METHODS = { · 18 unmodified lines | |||
| 239 | subscribeServerLifecycle: "subscribeServerLifecycle", | 248 | subscribeServerLifecycle: "subscribeServerLifecycle", |
| 240 | subscribeAuthAccess: "subscribeAuthAccess", | 249 | subscribeAuthAccess: "subscribeAuthAccess", |
| 241 | subscribeBackgroundPolicy: "subscribeBackgroundPolicy", | 250 | subscribeBackgroundPolicy: "subscribeBackgroundPolicy", |
| 251 | subscribeResourceTelemetry: "subscribeResourceTelemetry", | ||
| 242 | } as const; | 252 | } as const; |
| 243 | 253 | ||
| 244 | export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { | 254 | export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { |
| @@ -318,6 +328,27 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make( · 73 unmodified lines | |||
| 318 | }, | 328 | }, |
| 319 | ); | 329 | ); |
| 320 | 330 | ||
| 331 | export const WsServerGetResourceTelemetryRpc = Rpc.make(WS_METHODS.serverGetResourceTelemetry, { | ||
| 332 | payload: Schema.Struct({}), | ||
| 333 | success: ResourceTelemetrySnapshot, | ||
| 334 | error: EnvironmentAuthorizationError, | ||
| 335 | }); | ||
| 336 | |||
| 337 | export const WsServerGetResourceTelemetryHistoryRpc = Rpc.make( | ||
| 338 | WS_METHODS.serverGetResourceTelemetryHistory, | ||
| 339 | { | ||
| 340 | payload: ResourceTelemetryHistoryInput, | ||
| 341 | success: ResourceTelemetryHistory, | ||
| 342 | error: EnvironmentAuthorizationError, | ||
| 343 | }, | ||
| 344 | ); | ||
| 345 | |||
| 346 | export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourceTelemetry, { | ||
| 347 | payload: Schema.Struct({}), | ||
| 348 | success: ResourceTelemetryRetryResult, | ||
| 349 | error: EnvironmentAuthorizationError, | ||
| 350 | }); | ||
| 351 | |||
| 321 | export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { | 352 | export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { |
| 322 | payload: ServerSignalProcessInput, | 353 | payload: ServerSignalProcessInput, |
| 323 | success: ServerSignalProcessResult, | 354 | success: ServerSignalProcessResult, |
| @@ -710,6 +741,13 @@ export const WsSubscribeBackgroundPolicyRpc = Rpc.make(WS_METHODS.subscribeBackg · 386 unmodified lines | |||
| 710 | stream: true, | 741 | stream: true, |
| 711 | }); | 742 | }); |
| 712 | 743 | ||
| 744 | export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTelemetry, { | ||
| 745 | payload: Schema.Struct({}), | ||
| 746 | success: ResourceTelemetrySnapshot, | ||
| 747 | error: EnvironmentAuthorizationError, | ||
| 748 | stream: true, | ||
| 749 | }); | ||
| 750 | |||
| 713 | export const WsRpcGroup = RpcGroup.make( | 751 | export const WsRpcGroup = RpcGroup.make( |
| 714 | WsServerGetConfigRpc, | 752 | WsServerGetConfigRpc, |
| 715 | WsServerRefreshProvidersRpc, | 753 | WsServerRefreshProvidersRpc, |
| @@ -722,6 +760,9 @@ export const WsRpcGroup = RpcGroup.make( · 6 unmodified lines | |||
| 722 | WsServerGetTraceDiagnosticsRpc, | 760 | WsServerGetTraceDiagnosticsRpc, |
| 723 | WsServerGetProcessDiagnosticsRpc, | 761 | WsServerGetProcessDiagnosticsRpc, |
| 724 | WsServerGetProcessResourceHistoryRpc, | 762 | WsServerGetProcessResourceHistoryRpc, |
| 763 | WsServerGetResourceTelemetryRpc, | ||
| 764 | WsServerGetResourceTelemetryHistoryRpc, | ||
| 765 | WsServerRetryResourceTelemetryRpc, | ||
| 725 | WsServerSignalProcessRpc, | 766 | WsServerSignalProcessRpc, |
| 726 | WsServerReportClientActivityRpc, | 767 | WsServerReportClientActivityRpc, |
| 727 | WsServerReportHostPowerStateRpc, | 768 | WsServerReportHostPowerStateRpc, |
| @@ -776,6 +817,7 @@ export const WsRpcGroup = RpcGroup.make( · 48 unmodified lines | |||
| 776 | WsSubscribeServerLifecycleRpc, | 817 | WsSubscribeServerLifecycleRpc, |
| 777 | WsSubscribeAuthAccessRpc, | 818 | WsSubscribeAuthAccessRpc, |
| 778 | WsSubscribeBackgroundPolicyRpc, | 819 | WsSubscribeBackgroundPolicyRpc, |
| 820 | WsSubscribeResourceTelemetryRpc, | ||
| 779 | WsOrchestrationDispatchCommandRpc, | 821 | WsOrchestrationDispatchCommandRpc, |
| 780 | WsOrchestrationGetTurnDiffRpc, | 822 | WsOrchestrationGetTurnDiffRpc, |
| 781 | WsOrchestrationGetFullThreadDiffRpc, | 823 | WsOrchestrationGetFullThreadDiffRpc, |
| @@ -302,6 +302,7 @@ export type ServerProcessSignal = typeof ServerProcessSignal.Type; · 301 unmodified lines | |||
| 302 | 302 | ||
| 303 | export const ServerProcessDiagnosticsEntry = Schema.Struct({ | 303 | export const ServerProcessDiagnosticsEntry = Schema.Struct({ |
| 304 | pid: PositiveInt, | 304 | pid: PositiveInt, |
| 305 | startTimeMs: NonNegativeInt, | ||
| 305 | ppid: NonNegativeInt, | 306 | ppid: NonNegativeInt, |
| 306 | pgid: Schema.Option(Schema.Int), | 307 | pgid: Schema.Option(Schema.Int), |
| 307 | status: TrimmedNonEmptyString, | 308 | status: TrimmedNonEmptyString, |
| @@ -383,6 +384,7 @@ export type ServerProcessResourceHistoryResult = typeof ServerProcessResourceHis · 75 unmodified lines | |||
| 383 | 384 | ||
| 384 | export const ServerSignalProcessInput = Schema.Struct({ | 385 | export const ServerSignalProcessInput = Schema.Struct({ |
| 385 | pid: PositiveInt, | 386 | pid: PositiveInt, |
| 387 | startTimeMs: NonNegativeInt, | ||
| 386 | signal: ServerProcessSignal, | 388 | signal: ServerProcessSignal, |
| 387 | }); | 389 | }); |
| 388 | export type ServerSignalProcessInput = typeof ServerSignalProcessInput.Type; | 390 | export type ServerSignalProcessInput = typeof ServerSignalProcessInput.Type; |
| @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; · 7 unmodified lines | |||
| 8 | import * as Logger from "effect/Logger"; | 8 | import * as Logger from "effect/Logger"; |
| 9 | import * as Order from "effect/Order"; | 9 | import * as Order from "effect/Order"; |
| 10 | import * as Path from "effect/Path"; | 10 | import * as Path from "effect/Path"; |
| 11 | import * as Ref from "effect/Ref"; | ||
| 11 | import * as References from "effect/References"; | 12 | import * as References from "effect/References"; |
| 12 | import * as Schema from "effect/Schema"; | 13 | import * as Schema from "effect/Schema"; |
| 13 | import * as Tracer from "effect/Tracer"; | 14 | import * as Tracer from "effect/Tracer"; |
| @@ -18,6 +19,7 @@ import { · 4 unmodified lines | |||
| 18 | makeLocalFileTracer, | 19 | makeLocalFileTracer, |
| 19 | makeTraceSink, | 20 | makeTraceSink, |
| 20 | type TraceRecord, | 21 | type TraceRecord, |
| 22 | type TraceSinkFlushStats, | ||
| 21 | } from "./observability.ts"; | 23 | } from "./observability.ts"; |
| 22 | 24 | ||
| 23 | describe("causeErrorTag", () => { | 25 | describe("causeErrorTag", () => { |
| @@ -167,6 +169,34 @@ describe("observability", () => { · 143 unmodified lines | |||
| 167 | ), | 169 | ), |
| 168 | ); | 170 | ); |
| 169 | 171 | ||
| 172 | it.effect("reports successful logical trace writes", () => | ||
| 173 | Effect.scoped( | ||
| 174 | Effect.gen(function* () { | ||
| 175 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 176 | const path = yield* Path.Path; | ||
| 177 | const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-trace-sink-" }); | ||
| 178 | const tracePath = path.join(tempDir, "shared.trace.ndjson"); | ||
| 179 | const reported = yield* Ref.make<ReadonlyArray<TraceSinkFlushStats>>([]); | ||
| 180 | |||
| 181 | const sink = yield* makeTraceSink({ | ||
| 182 | filePath: tracePath, | ||
| 183 | maxBytes: 1024, | ||
| 184 | maxFiles: 2, | ||
| 185 | batchWindowMs: 10_000, | ||
| 186 | onFlush: (stats) => Ref.update(reported, (current) => [...current, stats]), | ||
| 187 | }); | ||
| 188 | |||
| 189 | sink.push(makeRecord("attributed")); | ||
| 190 | yield* sink.flush; | ||
| 191 | |||
| 192 | const stats = yield* Ref.get(reported); | ||
| 193 | assert.equal(stats.length, 1); | ||
| 194 | assert.equal(stats[0]?.count, 1); | ||
| 195 | assert.isAbove(stats[0]?.logicalWriteBytes ?? 0, 0); | ||
| 196 | }), | ||
| 197 | ), | ||
| 198 | ); | ||
| 199 | |||
| 170 | it.effect("rotates the trace file when the configured max size is exceeded", () => | 200 | it.effect("rotates the trace file when the configured max size is exceeded", () => |
| 171 | Effect.scoped( | 201 | Effect.scoped( |
| 172 | Effect.gen(function* () { | 202 | Effect.gen(function* () { |
| @@ -9,6 +9,7 @@ import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; · 8 unmodified lines | |||
| 9 | import { RotatingFileSink } from "./logging.ts"; | 9 | import { RotatingFileSink } from "./logging.ts"; |
| 10 | 10 | ||
| 11 | const FLUSH_BUFFER_THRESHOLD = 32; | 11 | const FLUSH_BUFFER_THRESHOLD = 32; |
| 12 | const textEncoder = new TextEncoder(); | ||
| 12 | 13 | ||
| 13 | export type TraceAttributes = Readonly<Record<string, unknown>>; | 14 | export type TraceAttributes = Readonly<Record<string, unknown>>; |
| 14 | 15 | ||
| @@ -94,6 +95,13 @@ export interface TraceSinkOptions { · 79 unmodified lines | |||
| 94 | readonly maxBytes: number; | 95 | readonly maxBytes: number; |
| 95 | readonly maxFiles: number; | 96 | readonly maxFiles: number; |
| 96 | readonly batchWindowMs: number; | 97 | readonly batchWindowMs: number; |
| 98 | readonly onFlush?: (stats: TraceSinkFlushStats) => Effect.Effect<void>; | ||
| 99 | } | ||
| 100 | |||
| 101 | export interface TraceSinkFlushStats { | ||
| 102 | readonly logicalWriteBytes: number; | ||
| 103 | readonly count: number; | ||
| 104 | readonly durationMs: number; | ||
| 97 | } | 105 | } |
| 98 | 106 | ||
| 99 | export interface TraceSink { | 107 | export interface TraceSink { |
| @@ -263,23 +271,50 @@ export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: Trac · 163 unmodified lines | |||
| 263 | }); | 271 | }); |
| 264 | 272 | ||
| 265 | let buffer: Array<string> = []; | 273 | let buffer: Array<string> = []; |
| 274 | let pendingFlushStats: TraceSinkFlushStats = { | ||
| 275 | logicalWriteBytes: 0, | ||
| 276 | count: 0, | ||
| 277 | durationMs: 0, | ||
| 278 | }; | ||
| 266 | 279 | ||
| 267 | const flushUnsafe = () => { | 280 | const flushUnsafe = () => { |
| 268 | if (buffer.length === 0) { | 281 | if (buffer.length === 0) { |
| 269 | return; | 282 | return; |
| 270 | } | 283 | } |
| 271 | 284 | ||
| 272 | const chunk = buffer.join(""); | 285 | const records = buffer; |
| 286 | const chunk = records.join(""); | ||
| 273 | buffer = []; | 287 | buffer = []; |
| 288 | const startedAt = performance.now(); | ||
| 274 | 289 | ||
| 275 | try { | 290 | try { |
| 276 | sink.write(chunk); | 291 | sink.write(chunk); |
| 292 | pendingFlushStats = { | ||
| 293 | logicalWriteBytes: | ||
| 294 | pendingFlushStats.logicalWriteBytes + textEncoder.encode(chunk).byteLength, | ||
| 295 | count: pendingFlushStats.count + records.length, | ||
| 296 | durationMs: pendingFlushStats.durationMs + Math.max(0, performance.now() - startedAt), | ||
| 297 | }; | ||
| 277 | } catch { | 298 | } catch { |
| 278 | buffer.unshift(chunk); | 299 | buffer.unshift(...records); |
| 279 | } | 300 | } |
| 280 | }; | 301 | }; |
| 281 | 302 | ||
| 282 | const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false)); | 303 | const flush = Effect.sync(() => { |
| 304 | flushUnsafe(); | ||
| 305 | const stats = pendingFlushStats; | ||
| 306 | pendingFlushStats = { | ||
| 307 | logicalWriteBytes: 0, | ||
| 308 | count: 0, | ||
| 309 | durationMs: 0, | ||
| 310 | }; | ||
| 311 | return stats; | ||
| 312 | }).pipe( | ||
| 313 | Effect.flatMap((stats) => | ||
| 314 | stats.count > 0 && options.onFlush ? options.onFlush(stats).pipe(Effect.ignore) : Effect.void, | ||
| 315 | ), | ||
| 316 | Effect.withTracerEnabled(false), | ||
| 317 | ); | ||
| 283 | 318 | ||
| 284 | yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore)); | 319 | yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore)); |
| 285 | yield* Effect.forkScoped( | 320 | yield* Effect.forkScoped( |
| @@ -9,12 +9,15 @@ import { · 8 unmodified lines | |||
| 9 | createStageWorkspaceConfig, | 9 | createStageWorkspaceConfig, |
| 10 | createStagePnpmConfig, | 10 | createStagePnpmConfig, |
| 11 | DESKTOP_ASAR_UNPACK, | 11 | DESKTOP_ASAR_UNPACK, |
| 12 | DESKTOP_EXTRA_RESOURCES, | ||
| 12 | resolveDesktopRuntimeDependencies, | 13 | resolveDesktopRuntimeDependencies, |
| 13 | resolveFffNativeDependencies, | 14 | resolveFffNativeDependencies, |
| 14 | resolveBuildOptions, | 15 | resolveBuildOptions, |
| 15 | resolveDesktopBuildIconAssets, | 16 | resolveDesktopBuildIconAssets, |
| 16 | resolveDesktopProductName, | 17 | resolveDesktopProductName, |
| 17 | resolveDesktopUpdateChannel, | 18 | resolveDesktopUpdateChannel, |
| 19 | resolveResourceMonitorRustTargets, | ||
| 20 | resourceMonitorExecutableName, | ||
| 18 | resolveGitHubPublishConfig, | 21 | resolveGitHubPublishConfig, |
| 19 | resolveMockUpdateServerPort, | 22 | resolveMockUpdateServerPort, |
| 20 | resolveMockUpdateServerUrl, | 23 | resolveMockUpdateServerUrl, |
| @@ -175,6 +178,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { · 154 unmodified lines | |||
| 175 | assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]); | 178 | assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]); |
| 176 | }); | 179 | }); |
| 177 | 180 | ||
| 181 | it("stages the resource monitor as an external executable resource", () => { | ||
| 182 | assert.deepStrictEqual(DESKTOP_EXTRA_RESOURCES, [ | ||
| 183 | { | ||
| 184 | from: "apps/desktop/prod-resources/resource-monitor", | ||
| 185 | to: "resource-monitor", | ||
| 186 | }, | ||
| 187 | ]); | ||
| 188 | assert.deepStrictEqual(resolveResourceMonitorRustTargets("mac", "universal"), [ | ||
| 189 | "aarch64-apple-darwin", | ||
| 190 | "x86_64-apple-darwin", | ||
| 191 | ]); | ||
| 192 | assert.deepStrictEqual(resolveResourceMonitorRustTargets("linux", "x64"), [ | ||
| 193 | "x86_64-unknown-linux-gnu", | ||
| 194 | ]); | ||
| 195 | assert.deepStrictEqual(resolveResourceMonitorRustTargets("win", "arm64"), [ | ||
| 196 | "aarch64-pc-windows-msvc", | ||
| 197 | ]); | ||
| 198 | assert.equal(resourceMonitorExecutableName("mac"), "t3-resource-monitor"); | ||
| 199 | assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe"); | ||
| 200 | }); | ||
| 201 | |||
| 178 | it("promotes target fff binaries to direct staged dependencies", () => { | 202 | it("promotes target fff binaries to direct staged dependencies", () => { |
| 179 | assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { | 203 | assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { |
| 180 | "@ff-labs/fff-bin-darwin-arm64": "0.9.4", | 204 | "@ff-labs/fff-bin-darwin-arm64": "0.9.4", |
| @@ -72,6 +72,26 @@ interface PlatformConfig { · 71 unmodified lines | |||
| 72 | readonly archChoices: ReadonlyArray<typeof BuildArch.Type>; | 72 | readonly archChoices: ReadonlyArray<typeof BuildArch.Type>; |
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | export function resolveResourceMonitorRustTargets( | ||
| 76 | platform: typeof BuildPlatform.Type, | ||
| 77 | arch: typeof BuildArch.Type, | ||
| 78 | ): ReadonlyArray<string> { | ||
| 79 | if (platform === "mac") { | ||
| 80 | if (arch === "universal") { | ||
| 81 | return ["aarch64-apple-darwin", "x86_64-apple-darwin"]; | ||
| 82 | } | ||
| 83 | return [arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"]; | ||
| 84 | } | ||
| 85 | if (platform === "linux") { | ||
| 86 | return [arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"]; | ||
| 87 | } | ||
| 88 | return [arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"]; | ||
| 89 | } | ||
| 90 | |||
| 91 | export function resourceMonitorExecutableName(platform: typeof BuildPlatform.Type): string { | ||
| 92 | return platform === "win" ? "t3-resource-monitor.exe" : "t3-resource-monitor"; | ||
| 93 | } | ||
| 94 | |||
| 75 | const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = { | 95 | const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = { |
| 76 | mac: { | 96 | mac: { |
| 77 | cliFlag: "--mac", | 97 | cliFlag: "--mac", |
| @@ -292,6 +312,12 @@ interface StagePackageJson { · 214 unmodified lines | |||
| 292 | 312 | ||
| 293 | export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const; | 313 | export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const; |
| 294 | export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const; | 314 | export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const; |
| 315 | export const DESKTOP_EXTRA_RESOURCES = [ | ||
| 316 | { | ||
| 317 | from: "apps/desktop/prod-resources/resource-monitor", | ||
| 318 | to: "resource-monitor", | ||
| 319 | }, | ||
| 320 | ] as const; | ||
| 295 | 321 | ||
| 296 | export function resolveFffNativeDependencies( | 322 | export function resolveFffNativeDependencies( |
| 297 | platform: typeof BuildPlatform.Type, | 323 | platform: typeof BuildPlatform.Type, |
| @@ -497,6 +523,78 @@ const runCommand = Effect.fn("runCommand")(function* ( · 199 unmodified lines | |||
| 497 | } | 523 | } |
| 498 | }); | 524 | }); |
| 499 | 525 | ||
| 526 | const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function* (input: { | ||
| 527 | readonly repoRoot: string; | ||
| 528 | readonly stageResourcesDir: string; | ||
| 529 | readonly platform: typeof BuildPlatform.Type; | ||
| 530 | readonly arch: typeof BuildArch.Type; | ||
| 531 | readonly verbose: boolean; | ||
| 532 | }) { | ||
| 533 | const fs = yield* FileSystem.FileSystem; | ||
| 534 | const path = yield* Path.Path; | ||
| 535 | const manifestPath = path.join(input.repoRoot, "native/resource-monitor/Cargo.toml"); | ||
| 536 | const executableName = resourceMonitorExecutableName(input.platform); | ||
| 537 | const rustTargets = resolveResourceMonitorRustTargets(input.platform, input.arch); | ||
| 538 | const builtBinaries: string[] = []; | ||
| 539 | |||
| 540 | for (const rustTarget of rustTargets) { | ||
| 541 | const spawnCommand = yield* resolveSpawnCommand("cargo", [ | ||
| 542 | "build", | ||
| 543 | "--locked", | ||
| 544 | "--release", | ||
| 545 | "--manifest-path", | ||
| 546 | manifestPath, | ||
| 547 | "--target", | ||
| 548 | rustTarget, | ||
| 549 | ]); | ||
| 550 | yield* runCommand( | ||
| 551 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | ||
| 552 | cwd: input.repoRoot, | ||
| 553 | shell: spawnCommand.shell, | ||
| 554 | }), | ||
| 555 | { | ||
| 556 | label: `cargo build resource monitor (${rustTarget})`, | ||
| 557 | verbose: input.verbose, | ||
| 558 | }, | ||
| 559 | ); | ||
| 560 | |||
| 561 | const binaryPath = path.join( | ||
| 562 | input.repoRoot, | ||
| 563 | "native/resource-monitor/target", | ||
| 564 | rustTarget, | ||
| 565 | "release", | ||
| 566 | executableName, | ||
| 567 | ); | ||
| 568 | if (!(yield* fs.exists(binaryPath))) { | ||
| 569 | return yield* new BuildScriptError({ | ||
| 570 | message: `Resource monitor build did not produce ${binaryPath}.`, | ||
| 571 | }); | ||
| 572 | } | ||
| 573 | builtBinaries.push(binaryPath); | ||
| 574 | } | ||
| 575 | |||
| 576 | const destinationDirectory = path.join(input.stageResourcesDir, "resource-monitor"); | ||
| 577 | const destinationPath = path.join(destinationDirectory, executableName); | ||
| 578 | yield* fs.remove(destinationDirectory, { recursive: true, force: true }).pipe(Effect.ignore); | ||
| 579 | yield* fs.makeDirectory(destinationDirectory, { recursive: true }); | ||
| 580 | |||
| 581 | if (builtBinaries.length === 1) { | ||
| 582 | yield* fs.copyFile(builtBinaries[0]!, destinationPath); | ||
| 583 | } else { | ||
| 584 | yield* runCommand( | ||
| 585 | ChildProcess.make("lipo", ["-create", ...builtBinaries, "-output", destinationPath]), | ||
| 586 | { | ||
| 587 | label: "lipo resource monitor universal binary", | ||
| 588 | verbose: input.verbose, | ||
| 589 | }, | ||
| 590 | ); | ||
| 591 | } | ||
| 592 | |||
| 593 | if (input.platform !== "win") { | ||
| 594 | yield* fs.chmod(destinationPath, 0o755); | ||
| 595 | } | ||
| 596 | }); | ||
| 597 | |||
| 500 | function generateMacIconSet( | 598 | function generateMacIconSet( |
| 501 | sourcePng: string, | 599 | sourcePng: string, |
| 502 | targetIcns: string, | 600 | targetIcns: string, |
| @@ -755,6 +853,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( · 252 unmodified lines | |||
| 755 | directories: { | 853 | directories: { |
| 756 | buildResources: "apps/desktop/resources", | 854 | buildResources: "apps/desktop/resources", |
| 757 | }, | 855 | }, |
| 856 | extraResources: DESKTOP_EXTRA_RESOURCES, | ||
| 758 | }; | 857 | }; |
| 759 | const updateChannel = resolveDesktopUpdateChannel(version); | 858 | const updateChannel = resolveDesktopUpdateChannel(version); |
| 760 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); | 859 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); |
| @@ -941,6 +1040,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 180 unmodified lines | |||
| 941 | yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron")); | 1040 | yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron")); |
| 942 | yield* fs.copy(distDirs.desktopResources, stageResourcesDir); | 1041 | yield* fs.copy(distDirs.desktopResources, stageResourcesDir); |
| 943 | yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); | 1042 | yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); |
| 1043 | yield* stageResourceMonitor({ | ||
| 1044 | repoRoot, | ||
| 1045 | stageResourcesDir, | ||
| 1046 | platform: options.platform, | ||
| 1047 | arch: options.arch, | ||
| 1048 | verbose: options.verbose, | ||
| 1049 | }); | ||
| 944 | 1050 | ||
| 945 | yield* assertPlatformBuildResources( | 1051 | yield* assertPlatformBuildResources( |
| 946 | options.platform, | 1052 | options.platform, |