Skip to content

Commit ea646c0

Browse files
fix(server): stop Windows terminal polling from spiking CPU (#9476)
1 parent 95d9937 commit ea646c0

10 files changed

Lines changed: 404 additions & 40 deletions

File tree

‎apps/server/src/diagnostics/ProcessDiagnostics.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ function makeNativeSnapshot(
1919
processes: ResourceMonitorSnapshotEvent["processes"],
2020
): ResourceMonitorSnapshotEvent {
2121
return {
22-
version: 2,
22+
version: 3,
2323
type: "snapshot",
2424
sequence: 1,
2525
sampledAtUnixMs: DateTime.toEpochMillis(DateTime.makeUnsafe("2026-05-05T10:00:00.000Z")),

‎apps/server/src/resourceTelemetry/Model.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ function nativeSnapshot(
3939
sequence = 1,
4040
): ResourceMonitorSnapshotEvent {
4141
return {
42-
version: 2,
42+
version: 3,
4343
type: "snapshot",
4444
sequence,
4545
sampledAtUnixMs,

‎apps/server/src/resourceTelemetry/NativeTelemetryClient.ts‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
ResourceMonitorEvent,
66
ResourceMonitorExternalProcess,
77
ResourceMonitorHelloEvent,
8+
ResourceMonitorProcessTableEntry,
89
ResourceMonitorSnapshotEvent,
910
ResourceTelemetrySourceStatus,
1011
} from "@t3tools/contracts";
@@ -44,6 +45,7 @@ const BATTERY_SAMPLE_INTERVAL_MS = 5_000;
4445
const CONSTRAINED_SAMPLE_INTERVAL_MS = 15_000;
4546
const HANDSHAKE_TIMEOUT = Duration.seconds(5);
4647
const SAMPLE_REQUEST_TIMEOUT = Duration.seconds(5);
48+
const PROCESS_TABLE_REQUEST_TIMEOUT = Duration.seconds(5);
4749
const HISTORY_REQUEST_TIMEOUT = Duration.seconds(15);
4850
const INITIAL_RESTART_DELAY = Duration.millis(500);
4951
const MAX_RESTART_DELAY = Duration.seconds(10);
@@ -76,7 +78,7 @@ export class NativeTelemetryHandshakeTimedOut extends Schema.TaggedErrorClass<Na
7678
class NativeTelemetryRequestTimedOut extends Schema.TaggedErrorClass<NativeTelemetryRequestTimedOut>()(
7779
"NativeTelemetryRequestTimedOut",
7880
{
79-
operation: Schema.Literals(["readHistory", "sampleNow"]),
81+
operation: Schema.Literals(["processTable", "readHistory", "sampleNow"]),
8082
timeoutMs: Schema.Number,
8183
},
8284
) {
@@ -192,6 +194,10 @@ export class NativeTelemetryClient extends Context.Service<
192194
snapshot: HostPowerSnapshot,
193195
) => Effect.Effect<void, NativeTelemetryClientError>;
194196
readonly sampleNow: Effect.Effect<NativeTelemetrySnapshot, NativeTelemetryClientError>;
197+
readonly processTable: Effect.Effect<
198+
ReadonlyArray<ResourceMonitorProcessTableEntry>,
199+
NativeTelemetryClientError
200+
>;
195201
readonly retry: Effect.Effect<boolean>;
196202
readonly health: Effect.Effect<NativeTelemetryClientHealth>;
197203
readonly subscribeHealth: Effect.Effect<
@@ -382,6 +388,12 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu
382388
const pendingSamples = yield* Ref.make(
383389
new Map<string, Deferred.Deferred<NativeTelemetrySnapshot, NativeTelemetryClientError>>(),
384390
);
391+
const pendingProcessTables = yield* Ref.make(
392+
new Map<
393+
string,
394+
Deferred.Deferred<ReadonlyArray<ResourceMonitorProcessTableEntry>, NativeTelemetryClientError>
395+
>(),
396+
);
385397
const pendingHistories = yield* Ref.make(new Map<string, PendingHistoryRequest>());
386398
const snapshots = yield* PubSub.sliding<NativeTelemetrySnapshot>(8);
387399
const healthChanges = yield* PubSub.sliding<NativeTelemetryClientHealth>(4);
@@ -399,10 +411,14 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu
399411
const failPending = (error: NativeTelemetryClientError) =>
400412
Effect.gen(function* () {
401413
const samples = yield* Ref.getAndSet(pendingSamples, new Map());
414+
const processTables = yield* Ref.getAndSet(pendingProcessTables, new Map());
402415
const histories = yield* Ref.getAndSet(pendingHistories, new Map());
403416
yield* Effect.forEach(samples.values(), (deferred) => Deferred.fail(deferred, error), {
404417
discard: true,
405418
});
419+
yield* Effect.forEach(processTables.values(), (deferred) => Deferred.fail(deferred, error), {
420+
discard: true,
421+
});
406422
yield* Effect.forEach(
407423
histories.values(),
408424
(request) => Deferred.fail(request.deferred, error),
@@ -481,6 +497,21 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu
481497
}
482498
}
483499
});
500+
case "processTable":
501+
return Ref.modify(pendingProcessTables, (pending) => {
502+
const next = new Map(pending);
503+
const deferred = next.get(event.requestId);
504+
next.delete(event.requestId);
505+
return [Option.fromUndefinedOr(deferred), next] as const;
506+
}).pipe(
507+
Effect.flatMap(
508+
Option.match({
509+
onNone: () => Effect.void,
510+
onSome: (deferred) => Deferred.succeed(deferred, event.processes),
511+
}),
512+
),
513+
Effect.asVoid,
514+
);
484515
case "historyChunk":
485516
return Effect.gen(function* () {
486517
const latestSnapshot = event.snapshots.at(-1);
@@ -936,6 +967,60 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu
936967
);
937968
});
938969

970+
const processTable: NativeTelemetryClient["Service"]["processTable"] = Effect.gen(function* () {
971+
const current = yield* Ref.get(state);
972+
if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) {
973+
return yield* new NativeTelemetryUnavailable({
974+
reason: Option.getOrElse(current.lastError, () => "sidecar is not running"),
975+
});
976+
}
977+
978+
const requestId = yield* crypto.randomUUIDv4.pipe(
979+
Effect.mapError(
980+
(cause) => new NativeTelemetryCommandFailed({ operation: "createRequestId", cause }),
981+
),
982+
);
983+
const deferred = yield* Deferred.make<
984+
ReadonlyArray<ResourceMonitorProcessTableEntry>,
985+
NativeTelemetryClientError
986+
>();
987+
yield* Ref.update(pendingProcessTables, (pending) => {
988+
const next = new Map(pending);
989+
next.set(requestId, deferred);
990+
return next;
991+
});
992+
return yield* writeCommand(Option.getOrThrow(current.handle), {
993+
version: RESOURCE_MONITOR_PROTOCOL_VERSION,
994+
type: "processTable",
995+
requestId,
996+
}).pipe(
997+
Effect.andThen(
998+
Deferred.await(deferred).pipe(
999+
Effect.timeoutOption(PROCESS_TABLE_REQUEST_TIMEOUT),
1000+
Effect.flatMap(
1001+
Option.match({
1002+
onNone: () =>
1003+
Effect.fail(
1004+
new NativeTelemetryRequestTimedOut({
1005+
operation: "processTable",
1006+
timeoutMs: Duration.toMillis(PROCESS_TABLE_REQUEST_TIMEOUT),
1007+
}),
1008+
),
1009+
onSome: Effect.succeed,
1010+
}),
1011+
),
1012+
),
1013+
),
1014+
Effect.ensuring(
1015+
Ref.update(pendingProcessTables, (pending) => {
1016+
const next = new Map(pending);
1017+
next.delete(requestId);
1018+
return next;
1019+
}),
1020+
),
1021+
);
1022+
});
1023+
9391024
const health = currentHealth;
9401025

9411026
return NativeTelemetryClient.of({
@@ -957,6 +1042,7 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu
9571042
setExternalProcesses,
9581043
setHostPowerState,
9591044
sampleNow,
1045+
processTable,
9601046
retry: Ref.get(state).pipe(
9611047
Effect.flatMap((current) =>
9621048
!canRequestNativeTelemetryRetry(current.status, Option.isSome(current.handle))
@@ -1010,6 +1096,11 @@ export const layerTest = (
10101096
reason: "No resource monitor sample was configured for this test.",
10111097
}),
10121098
),
1099+
processTable: Effect.fail(
1100+
new NativeTelemetryUnavailable({
1101+
reason: "No resource monitor process table was configured for this test.",
1102+
}),
1103+
),
10131104
retry: Effect.succeed(false),
10141105
health,
10151106
subscribeHealth:

‎apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ function nativeSnapshot(input: {
8282
}),
8383
];
8484
return {
85-
version: 2,
85+
version: 3,
8686
type: "snapshot",
8787
sequence: input.sequence,
8888
sampledAtUnixMs: input.sampledAtUnixMs,
@@ -497,7 +497,7 @@ describe("ResourceTelemetry", () => {
497497
const nativeHealth = yield* Ref.make<NativeTelemetryClient.NativeTelemetryClientHealth>({
498498
status: "healthy",
499499
hello: Option.some({
500-
version: 2,
500+
version: 3,
501501
type: "hello",
502502
sidecarVersion: "0.1.0",
503503
sidecarPid: 9_000,

‎apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function snapshot(
6464
}),
6565
];
6666
return {
67-
version: 2,
67+
version: 3,
6868
type: "snapshot",
6969
sequence,
7070
sampledAtUnixMs,

‎apps/server/src/server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.
374374
const TerminalLayerLive = TerminalManager.layer.pipe(
375375
Layer.provide(PtyAdapterLive),
376376
Layer.provide(PortScannerLayerLive),
377+
Layer.provide(NativeTelemetryLayerLive),
377378
);
378379

379380
const PreviewLayerLive = Layer.empty.pipe(

‎apps/server/src/terminal/Manager.test.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "@t3tools/contracts";
1515
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1616
import * as Data from "effect/Data";
17+
import * as Clock from "effect/Clock";
1718
import * as Deferred from "effect/Deferred";
1819
import * as Duration from "effect/Duration";
1920
import * as Effect from "effect/Effect";
@@ -220,6 +221,10 @@ interface CreateManagerOptions {
220221
readonly childCommand: string | null;
221222
readonly processIds: ReadonlyArray<number>;
222223
}>;
224+
processTable?: Effect.Effect<
225+
ReadonlyArray<{ readonly pid: number; readonly ppid: number; readonly name: string }>,
226+
never
227+
>;
223228
subprocessPollIntervalMs?: number;
224229
processKillGraceMs?: number;
225230
maxRetainedInactiveSessions?: number;
@@ -265,6 +270,7 @@ const createManager = (
265270
...(options.subprocessInspector !== undefined
266271
? { subprocessInspector: options.subprocessInspector }
267272
: {}),
273+
...(options.processTable !== undefined ? { processTable: options.processTable } : {}),
268274
...(options.subprocessPollIntervalMs !== undefined
269275
? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }
270276
: {}),
@@ -1192,6 +1198,96 @@ it.layer(
11921198
}),
11931199
);
11941200

1201+
it("calculates snapshot failure backoff and success reset delays", () => {
1202+
assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 0), 1_000);
1203+
assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 1), 2_000);
1204+
assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 2), 4_000);
1205+
assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 30), 60_000);
1206+
});
1207+
1208+
it.effect("uses process snapshots from the resource monitor", () =>
1209+
Effect.gen(function* () {
1210+
let snapshotCalls = 0;
1211+
const { manager, getEvents } = yield* createManager(5, {
1212+
subprocessPollIntervalMs: 20,
1213+
processTable: Effect.sync(() => {
1214+
snapshotCalls += 1;
1215+
return [{ pid: 100, ppid: 9000, name: "ping.exe" }];
1216+
}),
1217+
}).pipe(Effect.provide(withHostPlatform("win32")));
1218+
1219+
yield* manager.open(openInput());
1220+
yield* waitFor(
1221+
Effect.map(getEvents, (events) =>
1222+
events.some(
1223+
(event) =>
1224+
event.type === "activity" && event.hasRunningSubprocess && event.label === "ping",
1225+
),
1226+
),
1227+
"1200 millis",
1228+
);
1229+
expect(snapshotCalls).toBeGreaterThan(0);
1230+
}),
1231+
);
1232+
1233+
it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () =>
1234+
Effect.gen(function* () {
1235+
const fallbackCalls: Array<number> = [];
1236+
const processRunner: ProcessRunner.ProcessRunner["Service"] = {
1237+
run: () =>
1238+
Clock.currentTimeMillis.pipe(
1239+
Effect.map((now) => {
1240+
fallbackCalls.push(now);
1241+
return {
1242+
stdout: " 100 9000 vim",
1243+
stderr: "",
1244+
code: ChildProcessSpawner.ExitCode(0),
1245+
timedOut: false,
1246+
stdoutTruncated: false,
1247+
stderrInvalidUtf8: false,
1248+
stdoutInvalidUtf8: false,
1249+
stderrTruncated: false,
1250+
};
1251+
}),
1252+
),
1253+
};
1254+
1255+
const { manager, getEvents } = yield* createManager(5, {
1256+
subprocessPollIntervalMs: 20,
1257+
processTable: Effect.fail("sidecar unavailable").pipe(
1258+
Effect.mapError((cause) => cause as never),
1259+
),
1260+
}).pipe(
1261+
Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
1262+
Effect.provide(withHostPlatform("linux")),
1263+
);
1264+
1265+
yield* manager.open(openInput());
1266+
// The fallback data is still applied while the sidecar is down.
1267+
yield* waitFor(
1268+
Effect.map(getEvents, (events) =>
1269+
events.some(
1270+
(event) =>
1271+
event.type === "activity" &&
1272+
event.hasRunningSubprocess === true &&
1273+
event.label === "vim",
1274+
),
1275+
),
1276+
"1200 millis",
1277+
);
1278+
1279+
yield* waitFor(
1280+
Effect.sync(() => fallbackCalls.length >= 4),
1281+
"2000 millis",
1282+
);
1283+
// Four snapshots at the 20 ms base cadence would span ~60 ms. Backoff
1284+
// (40 + 80 + 160 ms) stretches the same four snapshots past 150 ms, so
1285+
// a stalled sidecar no longer hot-loops the spawned fallback.
1286+
const spanMs = fallbackCalls[3]! - fallbackCalls[0]!;
1287+
expect(spanMs).toBeGreaterThan(150);
1288+
}),
1289+
);
1290+
11951291
it.effect("caps persisted history to configured line limit", () =>
11961292
Effect.gen(function* () {
11971293
const { manager, ptyAdapter } = yield* createManager(3);

0 commit comments

Comments
 (0)