diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 195902c3c92..7ec0ab80ae7 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -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>): string { return Object.entries(values) .flatMap(([name, value]) => [ @@ -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, @@ -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* () { @@ -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 = []; + 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 })), + ); + }); }); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 62a3b6efc91..8219f18b7a5 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -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"; @@ -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", + { + ...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", + { + ...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, { @@ -127,6 +166,18 @@ const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ 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) => names .map((name) => { @@ -175,13 +226,14 @@ const extractEnvironment = (output: string, names: ReadonlyArray): Envir }; const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: { + readonly probe: DesktopShellEnvironmentProbe; readonly command: string; readonly args: ReadonlyArray; readonly timeout: Duration.Duration; readonly shell?: boolean; }): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - return yield* spawner + const output = yield* spawner .string( ChildProcess.make(input.command, input.args, { shell: input.shell ?? false, @@ -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 = ( @@ -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, @@ -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,