Skip to content
Merged
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
57 changes: 55 additions & 2 deletions apps/desktop/src/shell/DesktopShellEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import * as ChildProcess from "effect/unstable/process/ChildProcess";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts";

const textEncoder = new TextEncoder();

const isDesktopShellEnvironmentCommandError = Schema.is(
DesktopShellEnvironment.DesktopShellEnvironmentCommandError,
);

function envOutput(values: Readonly<Record<string, string>>): string {
return Object.entries(values)
.flatMap(([name, value]) => [
Expand Down Expand Up @@ -59,6 +67,7 @@ function runShellEnvironment(input: {
readonly env: NodeJS.ProcessEnv;
readonly platform: NodeJS.Platform;
readonly handler: (command: ChildProcess.Command) => string;
readonly failure?: PlatformError.PlatformError;
}) {
const environmentLayer = Layer.succeed(
DesktopEnvironment.DesktopEnvironment,
Expand All @@ -68,7 +77,11 @@ function runShellEnvironment(input: {
);
const spawnerLayer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => Effect.succeed(makeProcess(input.handler(command)))),
ChildProcessSpawner.make((command) =>
input.failure === undefined
? Effect.succeed(makeProcess(input.handler(command)))
: Effect.fail(input.failure),
),
);

const program = Effect.gen(function* () {
Expand Down Expand Up @@ -229,4 +242,44 @@ describe("DesktopShellEnvironment", () => {
);
}),
);

it.effect("logs command failures with safe probe context and the exact cause", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/bin/bash",
PATH: "/usr/bin",
};
const cause = PlatformError.systemError({
_tag: "PermissionDenied",
module: "ChildProcess",
method: "spawn",
pathOrDescriptor: "/bin/bash",
});
const messages: Array<unknown> = [];
const logger = Logger.make(({ message }) => {
messages.push(message);
});

return runShellEnvironment({
env,
platform: "linux",
handler: () => "",
failure: cause,
}).pipe(
Effect.andThen(
Effect.sync(() => {
const errors = messages
.flatMap((message) => (Array.isArray(message) ? message : [message]))
.filter(isDesktopShellEnvironmentCommandError);
assert.lengthOf(errors, 1);
assert.equal(errors[0]?.probe, "login-shell");
assert.equal(errors[0]?.executable, "bash");
assert.equal(errors[0]?.argumentCount, 2);
assert.notProperty(errors[0] ?? {}, "args");
assert.equal(errors[0]?.cause, cause);
assert.notInclude(errors[0]?.message ?? "", cause.message);
}),
),
Effect.provide(Logger.layer([logger], { mergeWithExisting: false })),
);
});
});
84 changes: 81 additions & 3 deletions apps/desktop/src/shell/DesktopShellEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as ChildProcess from "effect/unstable/process/ChildProcess";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

Expand All @@ -20,6 +21,44 @@ interface WindowsProbeOptions {
readonly loadProfile: boolean;
}

const DesktopShellEnvironmentProbe = Schema.Literals([
"login-shell",
"launchctl-path",
"powershell-profile",
"powershell-no-profile",
]);
type DesktopShellEnvironmentProbe = typeof DesktopShellEnvironmentProbe.Type;

const desktopShellEnvironmentCommandFields = {
probe: DesktopShellEnvironmentProbe,
executable: Schema.String,
argumentCount: Schema.Number,
};

export class DesktopShellEnvironmentCommandError extends Schema.TaggedErrorClass<DesktopShellEnvironmentCommandError>()(
"DesktopShellEnvironmentCommandError",
{
...desktopShellEnvironmentCommandFields,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Desktop shell environment ${this.probe} probe (${this.executable}) failed.`;
}
}

export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErrorClass<DesktopShellEnvironmentCommandTimeoutError>()(
"DesktopShellEnvironmentCommandTimeoutError",
{
...desktopShellEnvironmentCommandFields,
timeoutMs: Schema.Number,
},
) {
override get message(): string {
return `Desktop shell environment ${this.probe} probe (${this.executable}) timed out after ${this.timeoutMs}ms.`;
}
}

export class DesktopShellEnvironment extends Context.Service<
DesktopShellEnvironment,
{
Expand Down Expand Up @@ -127,6 +166,18 @@ const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [
const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`;
const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`;

const executableName = (command: string): string => command.split(/[\\/]/u).at(-1) ?? command;

const logShellEnvironmentCommandError = (
error: DesktopShellEnvironmentCommandError | DesktopShellEnvironmentCommandTimeoutError,
) =>
Effect.logWarning(error).pipe(
Effect.annotateLogs({
component: "desktop-shell-environment",
error,
}),
);

const capturePosixEnvironmentCommand = (names: ReadonlyArray<string>) =>
names
.map((name) => {
Expand Down Expand Up @@ -175,13 +226,14 @@ const extractEnvironment = (output: string, names: ReadonlyArray<string>): Envir
};

const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: {
readonly probe: DesktopShellEnvironmentProbe;
readonly command: string;
readonly args: ReadonlyArray<string>;
readonly timeout: Duration.Duration;
readonly shell?: boolean;
}): Effect.fn.Return<string, never, ChildProcessSpawner.ChildProcessSpawner> {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
return yield* spawner
const output = yield* spawner
.string(
ChildProcess.make(input.command, input.args, {
shell: input.shell ?? false,
Expand All @@ -193,10 +245,33 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(
}),
)
.pipe(
Effect.mapError(
(cause) =>
new DesktopShellEnvironmentCommandError({
probe: input.probe,
executable: executableName(input.command),
argumentCount: input.args.length,
cause,
}),
),
Effect.catchTags({
DesktopShellEnvironmentCommandError: (error) =>
logShellEnvironmentCommandError(error).pipe(Effect.as("")),
}),
Effect.timeoutOption(input.timeout),
Effect.map(Option.getOrElse(() => "")),
Effect.orElseSucceed(() => ""),
);
if (Option.isSome(output)) {
return output.value;
}

const error = new DesktopShellEnvironmentCommandTimeoutError({
probe: input.probe,
executable: executableName(input.command),
argumentCount: input.args.length,
timeoutMs: Duration.toMillis(input.timeout),
});
yield* logShellEnvironmentCommandError(error);
return "";
});

const readLoginShellEnvironment = (
Expand All @@ -206,12 +281,14 @@ const readLoginShellEnvironment = (
names.length === 0
? Effect.succeed({})
: runCommandOutput({
probe: "login-shell",
command: shell,
args: ["-ilc", capturePosixEnvironmentCommand(names)],
timeout: LOGIN_SHELL_TIMEOUT,
}).pipe(Effect.map((output) => extractEnvironment(output, names)));

const readLaunchctlPath = runCommandOutput({
probe: "launchctl-path",
command: "/bin/launchctl",
args: ["getenv", "PATH"],
timeout: LAUNCHCTL_TIMEOUT,
Expand All @@ -234,6 +311,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn

for (const command of WINDOWS_SHELL_CANDIDATES) {
const output = yield* runCommandOutput({
probe: options.loadProfile ? "powershell-profile" : "powershell-no-profile",
command,
args,
timeout: LOGIN_SHELL_TIMEOUT,
Expand Down
Loading