diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 0c7869194323c..c00e2ea7c65a2 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -637,6 +637,52 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("recovers the login-shell PATH when the first probe outlasts the timeout", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }; + const output = envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" }); + let shellProbes = 0; + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => "", + processSpawner: ChildProcessSpawner.make((command) => { + if (command._tag !== "StandardCommand" || command.command !== "/bin/zsh") { + return Effect.succeed(makeProcess("")); + } + shellProbes += 1; + if (shellProbes > 1) return Effect.succeed(makeProcess(output)); + // A cold start: the first login shell answers, but only after 7s. + const slow = Effect.sleep("7 seconds"); + const stdout = Stream.fromEffect(slow.pipe(Effect.as(textEncoder.encode(output)))); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(125), + stdout, + stderr: Stream.empty, + all: stdout, + exitCode: slow.pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ); + }), + use: (service) => + Effect.gen(function* () { + yield* service.installIntoProcess; + yield* TestClock.adjust("10 minutes"); + return true; + }), + }).pipe(Effect.provide(Logger.layer([]))); + + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"); + }), + ); + it.effect("loads PowerShell profile environment on Windows", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 9d9e390c95496..f0983ca622490 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -117,6 +117,8 @@ const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const; const FALLBACK_LC_CTYPE = "en_US.UTF-8"; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); +// A profile can be slow only while the machine boots; one longer wait covers it. +const SLOW_LOGIN_SHELL_TIMEOUT = Duration.seconds(20); const LAUNCHCTL_TIMEOUT = Duration.seconds(2); const PROCESS_TERMINATE_GRACE = Duration.seconds(1); @@ -261,7 +263,11 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( readonly env: NodeJS.ProcessEnv; readonly timeout: Duration.Duration; readonly shell?: boolean; -}): Effect.fn.Return { +}): Effect.fn.Return< + string, + DesktopShellEnvironmentCommandTimeoutError, + ChildProcessSpawner.ChildProcessSpawner +> { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fiber = yield* Effect.scoped( Effect.gen(function* () { @@ -317,21 +323,34 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( return output.value; } - const error = new DesktopShellEnvironmentCommandTimeoutError({ + return yield* new DesktopShellEnvironmentCommandTimeoutError({ probe: input.probe, executable: executableName(input.command), argumentCount: input.args.length, timeoutMs: Duration.toMillis(input.timeout), }); - yield* logShellEnvironmentCommandError(error); - return ""; }); +const orEmptyOnTimeout = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe( + Effect.catchTags({ + DesktopShellEnvironmentCommandTimeoutError: (error) => + logShellEnvironmentCommandError(error).pipe(Effect.as("")), + }), + ); + const readLoginShellEnvironment = ( shell: string, names: ReadonlyArray, env: NodeJS.ProcessEnv, -): Effect.Effect => + timeout: Duration.Duration, +): Effect.Effect< + EnvironmentPatch, + DesktopShellEnvironmentCommandTimeoutError, + ChildProcessSpawner.ChildProcessSpawner +> => names.length === 0 ? Effect.succeed({}) : runCommandOutput({ @@ -339,7 +358,7 @@ const readLoginShellEnvironment = ( command: shell, args: ["-ilc", buildEnvironmentCaptureCommand(shell, names)], env, - timeout: LOGIN_SHELL_TIMEOUT, + timeout, }).pipe(Effect.map((output) => extractEnvironment(output, names))); const readLaunchctlPath = (env: NodeJS.ProcessEnv) => @@ -349,7 +368,7 @@ const readLaunchctlPath = (env: NodeJS.ProcessEnv) => command: "/bin/launchctl", args: ["getenv", "PATH"], timeout: LAUNCHCTL_TIMEOUT, - }).pipe(Effect.map(trimNonEmpty)); + }).pipe(orEmptyOnTimeout, Effect.map(trimNonEmpty)); const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")( function* ( @@ -374,7 +393,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn args, env, timeout: LOGIN_SHELL_TIMEOUT, - }); + }).pipe(orEmptyOnTimeout); const environment = extractEnvironment(output, names); if (Object.keys(environment).length > 0) { return environment; @@ -429,10 +448,27 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix const userShells = [config.env.SHELL?.trim(), readUserLoginShell()]; let answeredBy: string | undefined; + let waitedForSlowShell = false; for (const shell of listLoginShellCandidates(config.platform, config.env.SHELL)) { + const read = (timeout: Duration.Duration) => + readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES, config.env, timeout); Object.assign( shellEnvironment, - yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES, config.env), + yield* read(LOGIN_SHELL_TIMEOUT).pipe( + Effect.catchTags({ + DesktopShellEnvironmentCommandTimeoutError: (error) => { + const retry = !waitedForSlowShell; + waitedForSlowShell = true; + return logShellEnvironmentCommandError(error).pipe( + Effect.andThen(retry ? read(SLOW_LOGIN_SHELL_TIMEOUT) : Effect.succeed({})), + ); + }, + }), + Effect.catchTags({ + DesktopShellEnvironmentCommandTimeoutError: (error) => + logShellEnvironmentCommandError(error).pipe(Effect.as({})), + }), + ), ); if (shellEnvironment.PATH) { answeredBy = shell;