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
46 changes: 46 additions & 0 deletions apps/desktop/src/shell/DesktopShellEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
54 changes: 45 additions & 9 deletions apps/desktop/src/shell/DesktopShellEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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<string, never, ChildProcessSpawner.ChildProcessSpawner> {
}): Effect.fn.Return<
string,
DesktopShellEnvironmentCommandTimeoutError,
ChildProcessSpawner.ChildProcessSpawner
> {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fiber = yield* Effect.scoped(
Effect.gen(function* () {
Expand Down Expand Up @@ -317,29 +323,42 @@ 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 = <R>(
effect: Effect.Effect<string, DesktopShellEnvironmentCommandTimeoutError, R>,
): Effect.Effect<string, never, R> =>
effect.pipe(
Effect.catchTags({
DesktopShellEnvironmentCommandTimeoutError: (error) =>
logShellEnvironmentCommandError(error).pipe(Effect.as("")),
}),
);

const readLoginShellEnvironment = (
shell: string,
names: ReadonlyArray<string>,
env: NodeJS.ProcessEnv,
): Effect.Effect<EnvironmentPatch, never, ChildProcessSpawner.ChildProcessSpawner> =>
timeout: Duration.Duration,
): Effect.Effect<
EnvironmentPatch,
DesktopShellEnvironmentCommandTimeoutError,
ChildProcessSpawner.ChildProcessSpawner
> =>
names.length === 0
? Effect.succeed({})
: runCommandOutput({
probe: "login-shell",
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) =>
Expand All @@ -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* (
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading