922 lines · 30.8 KB · TypeScript
RawDownload
import type {  HostPowerSnapshot,  ResourceMonitorCapabilities,  ResourceMonitorCommand,  ResourceMonitorEvent,  ResourceMonitorExternalProcess,  ResourceMonitorHelloEvent,  ResourceMonitorSnapshotEvent,  ResourceTelemetrySourceStatus,} from "@t3tools/contracts";import {  RESOURCE_MONITOR_PROTOCOL_VERSION,  ResourceMonitorCommand as ResourceMonitorCommandSchema,  ResourceMonitorEvent as ResourceMonitorEventSchema,} from "@t3tools/contracts";import * as Cause from "effect/Cause";import * as Context from "effect/Context";import * as Crypto from "effect/Crypto";import * as DateTime from "effect/DateTime";import * as Deferred from "effect/Deferred";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Fiber from "effect/Fiber";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as PubSub from "effect/PubSub";import * as Queue from "effect/Queue";import * as Ref from "effect/Ref";import * as Result from "effect/Result";import * as Schema from "effect/Schema";import * as Semaphore from "effect/Semaphore";import * as Stream from "effect/Stream";import * as Ndjson from "effect/unstable/encoding/Ndjson";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts";import { ServerConfig } from "../config.ts"; const SAMPLE_INTERVAL_MS = 1_000;const UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS = 5_000;const BATTERY_SAMPLE_INTERVAL_MS = 5_000;const CONSTRAINED_SAMPLE_INTERVAL_MS = 15_000;const HANDSHAKE_TIMEOUT = Duration.seconds(5);const SAMPLE_REQUEST_TIMEOUT = Duration.seconds(5);const HISTORY_REQUEST_TIMEOUT = Duration.seconds(15);const INITIAL_RESTART_DELAY = Duration.millis(500);const MAX_RESTART_DELAY = Duration.seconds(10);const FAILURE_WINDOW_MS = 60_000;const MAX_FAILURES_PER_WINDOW = 5; export class NativeTelemetrySpawnFailed extends Schema.TaggedErrorClass<NativeTelemetrySpawnFailed>()(  "NativeTelemetrySpawnFailed",  {    path: Schema.String,    cause: Schema.Defect(),  },) {  override get message(): string {    return `Failed to start resource monitor '${this.path}'.`;  }} export class NativeTelemetryHandshakeTimedOut extends Schema.TaggedErrorClass<NativeTelemetryHandshakeTimedOut>()(  "NativeTelemetryHandshakeTimedOut",  {    timeoutMs: Schema.Number,  },) {  override get message(): string {    return `Resource monitor handshake timed out after ${this.timeoutMs}ms.`;  }} export class NativeTelemetryProtocolMismatch extends Schema.TaggedErrorClass<NativeTelemetryProtocolMismatch>()(  "NativeTelemetryProtocolMismatch",  {    expectedVersion: Schema.Number,    receivedVersion: Schema.Number,  },) {  override get message(): string {    return `Resource monitor protocol ${this.receivedVersion} is incompatible with expected protocol ${this.expectedVersion}.`;  }} export class NativeTelemetryDecodeFailed extends Schema.TaggedErrorClass<NativeTelemetryDecodeFailed>()(  "NativeTelemetryDecodeFailed",  {    cause: Schema.Defect(),  },) {  override get message(): string {    return "Failed to decode resource monitor output.";  }} export class NativeTelemetryCommandFailed extends Schema.TaggedErrorClass<NativeTelemetryCommandFailed>()(  "NativeTelemetryCommandFailed",  {    operation: Schema.String,    cause: Schema.Defect(),  },) {  override get message(): string {    return `Resource monitor command '${this.operation}' failed.`;  }} export class NativeTelemetryExited extends Schema.TaggedErrorClass<NativeTelemetryExited>()(  "NativeTelemetryExited",  {    exitCode: Schema.Number,  },) {  override get message(): string {    return `Resource monitor exited with code ${this.exitCode}.`;  }} export class NativeTelemetryUnavailable extends Schema.TaggedErrorClass<NativeTelemetryUnavailable>()(  "NativeTelemetryUnavailable",  {    reason: Schema.String,  },) {  override get message(): string {    return `Resource monitor is unavailable: ${this.reason}`;  }} export type NativeTelemetryClientError =  | NativeTelemetrySpawnFailed  | NativeTelemetryHandshakeTimedOut  | NativeTelemetryProtocolMismatch  | NativeTelemetryDecodeFailed  | NativeTelemetryCommandFailed  | NativeTelemetryExited  | NativeTelemetryUnavailable; export interface NativeTelemetryClientHealth {  readonly status: ResourceTelemetrySourceStatus;  readonly hello: Option.Option<ResourceMonitorHelloEvent>;  readonly lastSampleAt: Option.Option<DateTime.Utc>;  readonly lastError: Option.Option<string>;  readonly restartCount: number;  readonly sampleIntervalMs: number;} export interface NativeTelemetryClientShape {  readonly capabilities: Effect.Effect<ResourceMonitorCapabilities, NativeTelemetryClientError>;  readonly snapshots: Stream.Stream<ResourceMonitorSnapshotEvent, NativeTelemetryClientError>;  readonly readHistory: (    windowMs: number,  ) => Effect.Effect<ReadonlyArray<ResourceMonitorSnapshotEvent>, NativeTelemetryClientError>;  readonly setExternalProcesses: (    processes: ReadonlyArray<ResourceMonitorExternalProcess>,  ) => Effect.Effect<void, NativeTelemetryClientError>;  readonly setHostPowerState: (    snapshot: HostPowerSnapshot,  ) => Effect.Effect<void, NativeTelemetryClientError>;  readonly sampleNow: Effect.Effect<ResourceMonitorSnapshotEvent, NativeTelemetryClientError>;  readonly retry: Effect.Effect<boolean>;  readonly health: Effect.Effect<NativeTelemetryClientHealth>;  readonly healthChanges: Stream.Stream<NativeTelemetryClientHealth>;} export class NativeTelemetryClient extends Context.Service<  NativeTelemetryClient,  NativeTelemetryClientShape>()("t3/resourceTelemetry/NativeTelemetryClient") {} interface ClientState {  readonly status: ResourceTelemetrySourceStatus;  readonly handle: Option.Option<ChildProcessSpawner.ChildProcessHandle>;  readonly hello: Option.Option<ResourceMonitorHelloEvent>;  readonly lastSampleAt: Option.Option<DateTime.Utc>;  readonly lastError: Option.Option<string>;  readonly restartCount: number;} interface CollectionControl {  readonly hostPower: HostPowerSnapshot;  readonly liveSubscriberCount: number;  readonly sampleIntervalMs: number;} interface PendingHistoryRequest {  readonly deferred: Deferred.Deferred<    ReadonlyArray<ResourceMonitorSnapshotEvent>,    NativeTelemetryClientError  >;  readonly snapshots: ReadonlyArray<ResourceMonitorSnapshotEvent>;} const initialState: ClientState = {  status: "starting",  handle: Option.none(),  hello: Option.none(),  lastSampleAt: Option.none(),  lastError: Option.none(),  restartCount: 0,}; function toHealth(state: ClientState, sampleIntervalMs: number): NativeTelemetryClientHealth {  return {    status: state.status,    hello: state.hello,    lastSampleAt: state.lastSampleAt,    lastError: state.lastError,    restartCount: state.restartCount,    sampleIntervalMs,  };} function isThermallyConstrained(snapshot: HostPowerSnapshot): boolean {  return snapshot.thermalState === "serious" || snapshot.thermalState === "critical";} export function resolveNativeSampleIntervalMs(  snapshot: HostPowerSnapshot,  liveSubscriberCount: number,): number {  if (snapshot.stale || snapshot.source === "unknown") {    return liveSubscriberCount > 0 ? SAMPLE_INTERVAL_MS : UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS;  }  if (snapshot.suspended) return 0;  if (    snapshot.locked === "true" ||    snapshot.lowPowerMode === "true" ||    isThermallyConstrained(snapshot)  ) {    return CONSTRAINED_SAMPLE_INTERVAL_MS;  }  if (snapshot.onBattery === "true") return BATTERY_SAMPLE_INTERVAL_MS;  return SAMPLE_INTERVAL_MS;} const decodeMonitorEvent: (  value: unknown,) => Effect.Effect<ResourceMonitorEvent, Schema.SchemaError> = Schema.decodeUnknownEffect(  ResourceMonitorEventSchema,);const encodeMonitorCommand = Schema.encodeEffect(  Schema.fromJsonString(ResourceMonitorCommandSchema),);const isProtocolMismatch = Schema.is(NativeTelemetryProtocolMismatch);const isDecodeFailed = Schema.is(NativeTelemetryDecodeFailed);const isCommandFailed = Schema.is(NativeTelemetryCommandFailed); function eventVersion(value: unknown): number | undefined {  if (typeof value !== "object" || value === null) return undefined;  const version = Reflect.get(value, "version");  return typeof version === "number" ? version : undefined;} function restartDelay(attempt: number): Duration.Duration {  return Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY);} function errorMessage(error: NativeTelemetryClientError): string {  return error.message;} export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(function* () {  const binary = yield* ResourceMonitorBinary.ResourceMonitorBinary;  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;  const crypto = yield* Crypto.Crypto;  const config = yield* ServerConfig;  const initializedAt = yield* DateTime.now;  const state = yield* Ref.make(initialState);  const collectionControl = yield* Ref.make<CollectionControl>({    hostPower: {      source: "unknown",      idle: "unknown",      idleSeconds: null,      locked: "unknown",      suspended: false,      onBattery: "unknown",      lowPowerMode: "unknown",      thermalState: "unknown",      stale: true,      updatedAt: initializedAt,    },    liveSubscriberCount: 0,    sampleIntervalMs: UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS,  });  const externalProcesses = yield* Ref.make<ReadonlyArray<ResourceMonitorExternalProcess>>([]);  const pendingSamples = yield* Ref.make(    new Map<string, Deferred.Deferred<ResourceMonitorSnapshotEvent, NativeTelemetryClientError>>(),  );  const pendingHistories = yield* Ref.make(new Map<string, PendingHistoryRequest>());  const snapshots = yield* PubSub.sliding<ResourceMonitorSnapshotEvent>(8);  const healthChanges = yield* PubSub.sliding<NativeTelemetryClientHealth>(4);  const retryQueue = yield* Queue.sliding<void>(1);  const commandMutex = yield* Semaphore.make(1);  const currentHealth = Effect.all([Ref.get(state), Ref.get(collectionControl)]).pipe(    Effect.map(([current, control]) => toHealth(current, control.sampleIntervalMs)),  );  const publishHealth = currentHealth.pipe(    Effect.flatMap((health) => PubSub.publish(healthChanges, health)),    Effect.asVoid,  );   const failPending = (error: NativeTelemetryClientError) =>    Effect.gen(function* () {      const samples = yield* Ref.getAndSet(pendingSamples, new Map());      const histories = yield* Ref.getAndSet(pendingHistories, new Map());      yield* Effect.forEach(samples.values(), (deferred) => Deferred.fail(deferred, error), {        discard: true,      });      yield* Effect.forEach(        histories.values(),        (request) => Deferred.fail(request.deferred, error),        { discard: true },      );    });   const writeCommand = (    handle: ChildProcessSpawner.ChildProcessHandle,    command: ResourceMonitorCommand,  ): Effect.Effect<void, NativeTelemetryClientError> =>    commandMutex.withPermits(1)(      encodeMonitorCommand(command).pipe(        Effect.map((encoded) => `${encoded}\n`),        Effect.mapError(          (cause) =>            new NativeTelemetryCommandFailed({              operation: command.type,              cause,            }),        ),        Effect.flatMap((encoded) =>          Stream.run(Stream.encodeText(Stream.make(encoded)), handle.stdin),        ),        Effect.mapError(          (cause) =>            new NativeTelemetryCommandFailed({              operation: command.type,              cause,            }),        ),      ),    );   const processEvent = (    event: ResourceMonitorEvent,    helloDeferred: Deferred.Deferred<ResourceMonitorHelloEvent>,  ): Effect.Effect<void, NativeTelemetryClientError> => {    switch (event.type) {      case "hello":        return Ref.update(state, (current) => ({          ...current,          status: "starting" as const,          hello: Option.some(event),          lastError: Option.none(),        })).pipe(          Effect.andThen(publishHealth),          Effect.andThen(Deferred.succeed(helloDeferred, event)),          Effect.asVoid,        );      case "snapshot":        return Effect.gen(function* () {          const sampledAt = DateTime.makeUnsafe(event.sampledAtUnixMs);          yield* Ref.update(state, (current) => ({            ...current,            status: "healthy" as const,            lastSampleAt: Option.some(sampledAt),            lastError: Option.none(),          }));          yield* PubSub.publish(snapshots, event);          if (event.requestId) {            const deferred = yield* Ref.modify(pendingSamples, (pending) => {              const next = new Map(pending);              const current = next.get(event.requestId!);              next.delete(event.requestId!);              return [Option.fromUndefinedOr(current), next];            });            if (Option.isSome(deferred)) {              yield* Deferred.succeed(deferred.value, event);            }          }        });      case "historyChunk":        return Effect.gen(function* () {          const latestSnapshot = event.snapshots.at(-1);          if (latestSnapshot) {            yield* Ref.update(state, (current) => ({              ...current,              status: "healthy" as const,              lastSampleAt: Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)),              lastError: Option.none(),            }));          }          const completed = yield* Ref.modify(pendingHistories, (pending) => {            const request = pending.get(event.requestId);            if (!request) return [Option.none(), pending] as const;            const snapshots = [...request.snapshots, ...event.snapshots];            const next = new Map(pending);            if (event.done) {              next.delete(event.requestId);              return [Option.some({ deferred: request.deferred, snapshots }), next] as const;            }            next.set(event.requestId, { deferred: request.deferred, snapshots });            return [Option.none(), next] as const;          });          if (Option.isSome(completed)) {            yield* Deferred.succeed(completed.value.deferred, completed.value.snapshots);          }        });      case "error":        return Ref.update(state, (current) => ({          ...current,          status: "degraded" as const,          lastError: Option.some(event.message),        })).pipe(          Effect.andThen(publishHealth),          Effect.andThen(            event.recoverable              ? Effect.void              : Effect.fail(                  new NativeTelemetryCommandFailed({                    operation: event.code,                    cause: event.message,                  }),                ),          ),        );    }  };   const runAttempt: Effect.Effect<void, NativeTelemetryClientError> = Effect.scoped(    Effect.gen(function* () {      const executablePath = yield* binary.resolve.pipe(        Effect.mapError(          (error) =>            new NativeTelemetryUnavailable({              reason: error.message,            }),        ),      );      const command = ChildProcess.make(executablePath, [], {        cwd: config.cwd,        stdin: {          stream: "pipe",          endOnDone: false,        },        stdout: "pipe",        stderr: "pipe",        killSignal: "SIGTERM",        forceKillAfter: Duration.seconds(2),      });      const handle = yield* Effect.acquireRelease(        spawner          .spawn(command)          .pipe(            Effect.mapError(              (cause) => new NativeTelemetrySpawnFailed({ path: executablePath, cause }),            ),          ),        (child) => child.kill().pipe(Effect.ignore),      );      yield* Ref.update(state, (current) => ({        ...current,        status: "starting" as const,        handle: Option.some(handle),        hello: Option.none(),      }));      yield* publishHealth;       const helloDeferred = yield* Deferred.make<ResourceMonitorHelloEvent>();      const eventFiber = yield* handle.stdout.pipe(        Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })),        Stream.mapEffect(          (            value,          ): Effect.Effect<            ResourceMonitorEvent,            NativeTelemetryProtocolMismatch | NativeTelemetryDecodeFailed          > => {            const version = eventVersion(value);            if (version !== undefined && version !== RESOURCE_MONITOR_PROTOCOL_VERSION) {              return Effect.fail(                new NativeTelemetryProtocolMismatch({                  expectedVersion: RESOURCE_MONITOR_PROTOCOL_VERSION,                  receivedVersion: version,                }),              );            }            return decodeMonitorEvent(value).pipe(              Effect.mapError((cause) => new NativeTelemetryDecodeFailed({ cause })),            );          },        ),        Stream.runForEach((event) => processEvent(event, helloDeferred)),        Effect.mapError((cause) =>          isProtocolMismatch(cause) || isDecodeFailed(cause) || isCommandFailed(cause)            ? cause            : new NativeTelemetryDecodeFailed({ cause }),        ),        Effect.forkScoped,

Lines 1–500 of 922.