Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/server/src/diagnostics/ProcessDiagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,44 @@ describe("ProcessDiagnostics", () => {
}),
);

it.effect("queries each Windows CIM class once", () =>
Effect.gen(function* () {
const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> =
[];
const spawnerLayer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => {
const childProcess = command as unknown as {
readonly command: string;
readonly args: ReadonlyArray<string>;
};
commands.push({ command: childProcess.command, args: childProcess.args });
return Effect.succeed(mockHandle({ stdout: "[]" }));
}),
);

yield* ProcessDiagnostics.readProcessRows.pipe(
Effect.provide(spawnerLayer),
Effect.provideService(HostProcessPlatform, "win32"),
);

expect(commands).toHaveLength(1);
const [command] = commands;
expect(command?.command).toBe("powershell.exe");
expect(command?.args.slice(0, 3)).toEqual(["-NoProfile", "-NonInteractive", "-Command"]);
const script = command?.args[3] ?? "";
expect(script.match(/Get-CimInstance Win32_Process/g)).toHaveLength(1);
expect(
script.match(/Get-CimInstance Win32_PerfFormattedData_PerfProc_Process/g),
).toHaveLength(1);
expect(script).toMatch(
/Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -ErrorAction SilentlyContinue/,
);
expect(script).toContain("$perfById[[int]$_.ProcessId]");
expect(script).not.toContain("-Filter");
}),
);

it.effect("keeps bounded command diagnostics when the process query exits unsuccessfully", () =>
Effect.gen(function* () {
const spawnerLayer = Layer.succeed(
Expand Down
14 changes: 10 additions & 4 deletions apps/server/src/diagnostics/ProcessDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export interface ProcessRow {
readonly command: string;
}

const PROCESS_QUERY_TIMEOUT_MS = 1_000;
const POSIX_PROCESS_QUERY_TIMEOUT_MS = 1_000;
const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 5_000;
const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";
const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;

Expand Down Expand Up @@ -340,6 +341,7 @@ interface ProcessOutput {
const runProcess = Effect.fn("runProcess")(function* (input: {
readonly command: string;
readonly args: ReadonlyArray<string>;
readonly timeoutMillis: number;
}) {
const cwd = process.cwd();
return yield* Effect.gen(function* () {
Expand Down Expand Up @@ -381,7 +383,7 @@ const runProcess = Effect.fn("runProcess")(function* (input: {
} satisfies ProcessOutput;
}).pipe(
Effect.scoped,
Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),
Effect.timeoutOption(Duration.millis(input.timeoutMillis)),
Effect.flatMap((result) =>
Option.match(result, {
onNone: () =>
Expand All @@ -390,7 +392,7 @@ const runProcess = Effect.fn("runProcess")(function* (input: {
command: input.command,
argCount: input.args.length,
cwd,
timeoutMillis: PROCESS_QUERY_TIMEOUT_MS,
timeoutMillis: input.timeoutMillis,
}),
),
onSome: Effect.succeed,
Expand All @@ -417,6 +419,7 @@ function readPosixProcessRows(): Effect.Effect<
return runProcess({
command: "ps",
args: ["-axo", POSIX_PROCESS_QUERY_COMMAND],
timeoutMillis: POSIX_PROCESS_QUERY_TIMEOUT_MS,
}).pipe(
Effect.flatMap((result) =>
result.exitCode !== 0
Expand All @@ -443,8 +446,10 @@ function readWindowsProcessRows(): Effect.Effect<
ChildProcessSpawner.ChildProcessSpawner
> {
const command = [
"$perfById = @{};",
"Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -ErrorAction SilentlyContinue | ForEach-Object { $perfById[[int]$_.IDProcess] = $_ };",
"$processes = Get-CimInstance Win32_Process | ForEach-Object {",
'$perf = Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -Filter "IDProcess = $($_.ProcessId)" -ErrorAction SilentlyContinue;',
"$perf = $perfById[[int]$_.ProcessId];",
"[pscustomobject]@{ ProcessId = $_.ProcessId; ParentProcessId = $_.ParentProcessId; Name = $_.Name; CommandLine = $_.CommandLine; Status = $_.Status; WorkingSetSize = $_.WorkingSetSize; PercentProcessorTime = if ($perf) { $perf.PercentProcessorTime } else { 0 } }",
"};",
"$processes | ConvertTo-Json -Compress -Depth 3",
Expand All @@ -453,6 +458,7 @@ function readWindowsProcessRows(): Effect.Effect<
return runProcess({
command: "powershell.exe",
args: ["-NoProfile", "-NonInteractive", "-Command", command],
timeoutMillis: WINDOWS_PROCESS_QUERY_TIMEOUT_MS,
}).pipe(
Effect.flatMap((result) =>
result.exitCode !== 0
Expand Down
19 changes: 5 additions & 14 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,7 @@ const testLayer = (input: {
return Layer.mergeAll(
ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))),
Layer.succeed(HostProcessPlatform, input.platform),
Layer.succeed(
SpawnExecutableResolution,
(command) => input.resolveExecutable?.(command) ?? command,
),
Layer.succeed(SpawnExecutableResolution, (command) => input.resolveExecutable?.(command)),
ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })),
);
};
Expand Down Expand Up @@ -132,27 +129,21 @@ it.effect("launches an installed editor with platform-safe arguments", () =>

it.effect("discovers editors through the service API", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n");
yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n");

const editors = yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
return yield* launcher.resolveAvailableEditors();
}).pipe(
Effect.provide(
testLayer({
platform: "win32",
env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
resolveExecutable: (command) =>
command === "code" || command === "explorer" ? command : undefined,
}),
),
);

assert.equal(editors.includes("vscode"), true);
assert.equal(editors.includes("file-manager"), true);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
assert.deepEqual(editors, ["vscode", "file-manager"]);
}),
);

it.effect("rejects unknown editors through the service API", () =>
Expand Down
45 changes: 28 additions & 17 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import {
type LaunchEditorInput,
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
import {
isCommandAvailable,
resolveSpawnCommand,
SpawnExecutableResolution,
} from "@t3tools/shared/shell";
import * as Config from "effect/Config";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -64,6 +68,7 @@ interface TargetPathAndPosition {
}

const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;
const EDITOR_DISCOVERY_CONCURRENCY = 4;
const POWERSHELL_ARGUMENTS_PREFIX = [
"-NoProfile",
"-NonInteractive",
Expand Down Expand Up @@ -264,24 +269,30 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors"
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv,
): Effect.fn.Return<ReadonlyArray<EditorId>, never, FileSystem.FileSystem | Path.Path> {
const available: EditorId[] = [];

for (const editor of EDITORS) {
if (editor.commands === null) {
const command = fileManagerCommandForPlatform(platform);
if (yield* isCommandAvailable(command, { env })) {
available.push(editor.id);
}
continue;
}

const command = yield* resolveAvailableCommand(editor.commands, env);
if (Option.isSome(command)) {
available.push(editor.id);
}
if (platform === "win32") {
// Use the existing fast resolver: repeated asynchronous PATH scans exceeded five seconds on the reported machine.
const resolveExecutable = yield* SpawnExecutableResolution;
return EDITORS.flatMap((editor) => {
const commands = editor.commands ?? [fileManagerCommandForPlatform(platform)];
return commands.some((command) => resolveExecutable(command, platform, env) !== undefined)
? [editor.id]
: [];
});
}

return available;
const availability = yield* Effect.all(
EDITORS.map((editor) => {
const probe =
editor.commands === null
? isCommandAvailable(fileManagerCommandForPlatform(platform), { env })
: resolveAvailableCommand(editor.commands, env).pipe(Effect.map(Option.isSome));

return probe;
}),
{ concurrency: EDITOR_DISCOVERY_CONCURRENCY },
);

return EDITORS.flatMap((editor, index) => (availability[index] ? [editor.id] : []));
});

const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* (
Expand Down
18 changes: 17 additions & 1 deletion apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import * as Deferred from "effect/Deferred";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as ManagedRuntime from "effect/ManagedRuntime";
Expand Down Expand Up @@ -72,6 +73,7 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");

import * as ServerConfig from "./config.ts";
import { makeRoutesLayer } from "./server.ts";
import { withEditorDiscoveryTimeout } from "./ws.ts";
import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
import * as GitManager from "./git/GitManager.ts";
import * as Keybindings from "./keybindings.ts";
Expand Down Expand Up @@ -3720,6 +3722,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("does not block server config on editor discovery", () =>
Effect.gen(function* () {
const responseFiber = yield* withEditorDiscoveryTimeout(
Effect.never,
Duration.seconds(5),
).pipe(Effect.forkChild);
yield* TestClock.adjust(Duration.seconds(5));
const response = yield* Fiber.join(responseFiber);

assert.deepEqual(response, []);
}),
);

it.effect(
"rejects websocket rpc handshake when a session token is only provided via query string",
() =>
Expand Down Expand Up @@ -4206,6 +4221,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {

it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const providers = [
{
instanceId: ProviderInstanceId.make("codex"),
Expand Down Expand Up @@ -4259,7 +4275,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
assert.deepEqual(first.config.keybindings, []);
assert.deepEqual(first.config.issues, []);
assert.deepEqual(first.config.providers, providers);
assert.equal(first.config.observability.logsDirectoryPath.endsWith("/logs"), true);
assert.equal(path.basename(first.config.observability.logsDirectoryPath), "logs");
assert.equal(first.config.observability.localTracingEnabled, true);
assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces");
assert.equal(first.config.observability.otlpTracesEnabled, true);
Expand Down
15 changes: 14 additions & 1 deletion apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ import * as RelayClient from "@t3tools/shared/relayClient";
const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);

const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
const EDITOR_DISCOVERY_TIMEOUT = Duration.seconds(5);
const EDITOR_DISCOVERY_FALLBACK = [] as const;

export const withEditorDiscoveryTimeout = <A, E, R>(
discovery: Effect.Effect<ReadonlyArray<A>, E, R>,
timeout: Duration.Input = EDITOR_DISCOVERY_TIMEOUT,
) =>
discovery.pipe(
Effect.timeoutOption(timeout),
Effect.map(Option.getOrElse(() => EDITOR_DISCOVERY_FALLBACK)),
);

function unexpectedCompatibilityError(error: never): never {
throw new Error(`Unhandled compatibility error: ${String(error)}`);
Expand Down Expand Up @@ -923,7 +934,9 @@ const makeWsRpcLayer = (
keybindings: keybindingsConfig.keybindings,
issues: keybindingsConfig.issues,
providers,
availableEditors: yield* externalLauncher.resolveAvailableEditors(),
availableEditors: yield* withEditorDiscoveryTimeout(
externalLauncher.resolveAvailableEditors(),
),
observability: {
logsDirectoryPath: config.logsDir,
localTracingEnabled: true,
Expand Down
Loading