Skip to content
Draft
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
5 changes: 4 additions & 1 deletion apps/desktop/src/ipc/methods/sshEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";

import {
DesktopSshEnvironmentRequestError,
fetchSshEnvironmentDescriptor,
} from "./sshEnvironment.ts";

const isSshHttpBridgeError = Schema.is(SshHttpBridgeError);

function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) {
return HttpClientResponse.fromWeb(
request,
Expand Down Expand Up @@ -83,7 +86,7 @@ describe("SSH environment IPC", () => {

assert.instanceOf(error, DesktopSshEnvironmentRequestError);
assert.equal(error.operation, "fetch-environment-descriptor");
assert.equal(error.cause instanceof SshHttpBridgeError, false);
assert.equal(isSshHttpBridgeError(error.cause), false);
}).pipe(Effect.provide(layer));
});

Expand Down
6 changes: 2 additions & 4 deletions apps/desktop/src/ipc/methods/sshEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,10 @@ const isEnvironmentInternalError = Schema.is(EnvironmentInternalError);
const isEnvironmentOperationForbiddenError = Schema.is(EnvironmentOperationForbiddenError);
const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError);
const isEnvironmentScopeRequiredError = Schema.is(EnvironmentScopeRequiredError);
const isSshHttpBridgeError = Schema.is(SshHttpBridgeError);

function readSshHttpStatus(cause: DesktopSshEnvironmentRequestCause): number | null {
if (
cause instanceof RemoteEnvironmentAuthUndeclaredStatusError ||
cause instanceof SshHttpBridgeError
) {
if (cause instanceof RemoteEnvironmentAuthUndeclaredStatusError || isSshHttpBridgeError(cause)) {
return cause.status ?? null;
}
if (isEnvironmentRequestInvalidError(cause)) {
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/ssh/DesktopSshEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

Expand Down Expand Up @@ -69,6 +70,8 @@ export interface DesktopSshEnvironmentLayerOptions {
readonly resolveCliRunner?: Effect.Effect<SshTunnel.RemoteT3RunnerOptions>;
}

const isSshPasswordPromptError = Schema.is(SshPasswordPromptError);

function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) {
return discoverSshHosts(input ?? {});
}
Expand All @@ -77,7 +80,7 @@ export function isDesktopSshPasswordPromptCancellation(
error: unknown,
): error is SshPasswordPromptError {
return (
error instanceof SshPasswordPromptError &&
isSshPasswordPromptError(error) &&
DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause)
);
}
Expand Down
50 changes: 49 additions & 1 deletion apps/server/src/diagnostics/ProcessDiagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "@effect/vitest";
import { assert, describe, expect, it } from "@effect/vitest";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -258,6 +258,54 @@ describe("ProcessDiagnostics", () => {
}),
);

it.effect("parses Windows process rows through Schema JSON decoding", () =>
Effect.gen(function* () {
const spawnerLayer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(
mockHandle({
stdout: JSON.stringify([
{
ProcessId: 10,
ParentProcessId: 1,
Name: "node.exe",
CommandLine: "node server.js",
Status: "Running",
WorkingSetSize: 2048,
PercentProcessorTime: 12.5,
},
{
ProcessId: "not-a-number",
ParentProcessId: 1,
Name: "ignored.exe",
},
]),
}),
),
),
);

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

assert.deepEqual(rows, [
{
pid: 10,
ppid: 1,
pgid: null,
status: "Running",
cpuPercent: 12.5,
rssBytes: 2048,
elapsed: "",
command: "node server.js",
},
]);
}),
);

it.effect("does not allow signaling the diagnostics query process", () =>
Effect.gen(function* () {
const spawnerLayer = Layer.succeed(
Expand Down
23 changes: 13 additions & 10 deletions apps/server/src/diagnostics/ProcessDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ const ProcessDiagnosticsError = Schema.Union([
]);
type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;
const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);
const decodeWindowsProcessRowsJson = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Unknown),
);

function parsePositiveInt(value: string): number | null {
const parsed = Number.parseInt(value, 10);
Expand Down Expand Up @@ -236,16 +239,16 @@ function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {

function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
if (output.trim().length === 0) return [];
try {
const parsed = JSON.parse(output) as unknown;
const records = Array.isArray(parsed) ? parsed : [parsed];
return records.flatMap((record) => {
const row = normalizeWindowsProcessRow(record);
return row ? [row] : [];
});
} catch {
return [];
}
return Option.match(decodeWindowsProcessRowsJson(output), {
onNone: () => [],
onSome: (parsed) => {
const records = Array.isArray(parsed) ? parsed : [parsed];
return records.flatMap((record) => {
const row = normalizeWindowsProcessRow(record);
return row ? [row] : [];
});
},
});
}

export function buildDescendantEntries(
Expand Down
109 changes: 62 additions & 47 deletions packages/ssh/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,62 @@
import * as Data from "effect/Data";

export class SshHostDiscoveryError extends Data.TaggedError("SshHostDiscoveryError")<{
readonly message: string;
readonly cause: unknown;
}> {}

export class SshInvalidTargetError extends Data.TaggedError("SshInvalidTargetError")<{
readonly message: string;
}> {}

export class SshCommandError extends Data.TaggedError("SshCommandError")<{
readonly message: string;
readonly command: readonly string[];
readonly exitCode: number | null;
readonly stderr: string;
readonly stdout?: string;
readonly cause?: unknown;
}> {}

export class SshLaunchError extends Data.TaggedError("SshLaunchError")<{
readonly message: string;
readonly stdout: string;
readonly cause?: unknown;
}> {}

export class SshPairingError extends Data.TaggedError("SshPairingError")<{
readonly message: string;
readonly stdout: string;
readonly cause?: unknown;
}> {}

export class SshHttpBridgeError extends Data.TaggedError("SshHttpBridgeError")<{
readonly message: string;
readonly status?: number;
readonly cause?: unknown;
}> {}

export class SshReadinessError extends Data.TaggedError("SshReadinessError")<{
readonly message: string;
readonly cause?: unknown;
}> {}

export class SshPasswordPromptError extends Data.TaggedError("SshPasswordPromptError")<{
readonly message: string;
readonly cause?: unknown;
}> {}
import * as Schema from "effect/Schema";

export class SshHostDiscoveryError extends Schema.TaggedErrorClass<SshHostDiscoveryError>()(
"SshHostDiscoveryError",
{
message: Schema.String,
cause: Schema.Defect(),
},
) {}

export class SshInvalidTargetError extends Schema.TaggedErrorClass<SshInvalidTargetError>()(
"SshInvalidTargetError",
{
message: Schema.String,
},
) {}

export class SshCommandError extends Schema.TaggedErrorClass<SshCommandError>()("SshCommandError", {
message: Schema.String,
command: Schema.Array(Schema.String),
exitCode: Schema.NullOr(Schema.Number),
stderr: Schema.String,
stdout: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
}) {}

export class SshLaunchError extends Schema.TaggedErrorClass<SshLaunchError>()("SshLaunchError", {
message: Schema.String,
stdout: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}

export class SshPairingError extends Schema.TaggedErrorClass<SshPairingError>()("SshPairingError", {
message: Schema.String,
stdout: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}

export class SshHttpBridgeError extends Schema.TaggedErrorClass<SshHttpBridgeError>()(
"SshHttpBridgeError",
{
message: Schema.String,
status: Schema.optional(Schema.Number),
cause: Schema.optional(Schema.Defect()),
},
) {}

export class SshReadinessError extends Schema.TaggedErrorClass<SshReadinessError>()(
"SshReadinessError",
{
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
},
) {}

export class SshPasswordPromptError extends Schema.TaggedErrorClass<SshPasswordPromptError>()(
"SshPasswordPromptError",
{
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
},
) {}
7 changes: 4 additions & 3 deletions packages/ssh/src/tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const SSH_READY_PROBE_TIMEOUT_MS = 1_000;
const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000;
const REMOTE_READY_TIMEOUT_MS = 15_000;
const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000;
const isSshReadinessError = Schema.is(SshReadinessError);

export interface RemoteT3RunnerOptions {
readonly packageSpec?: string;
Expand Down Expand Up @@ -233,7 +234,7 @@ function applyScriptPlaceholders(
}

export function describeReadinessCause(cause: unknown): unknown {
if (cause instanceof SshReadinessError) {
if (isSshReadinessError(cause)) {
return {
_tag: cause._tag,
message: cause.message,
Expand Down Expand Up @@ -922,7 +923,7 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio
});
}).pipe(
Effect.mapError((cause) =>
cause instanceof SshReadinessError
isSshReadinessError(cause)
? cause
: new SshReadinessError({
message: `Backend readiness probe failed at ${requestUrl}.`,
Expand All @@ -943,7 +944,7 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio

const result = yield* readinessClient.execute(HttpClientRequest.get(requestUrl)).pipe(
Effect.mapError((cause) =>
cause instanceof SshReadinessError
isSshReadinessError(cause)
? cause
: new SshReadinessError({
message: `Backend readiness probe failed at ${requestUrl}.`,
Expand Down
Loading