diff --git a/packages/desktop/src/main/service/shell-env.test.ts b/packages/desktop/src/main/service/shell-env.test.ts index 1982bbd2e5d..c0f1c833c1f 100644 --- a/packages/desktop/src/main/service/shell-env.test.ts +++ b/packages/desktop/src/main/service/shell-env.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from "bun:test" +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" import { NodePath } from "@effect/platform-node" import { Effect } from "effect" -import { isNushell, parseShellEnv, resolveUserShell } from "./shell-env" +import { isNushell, loadShellEnv, parseShellEnv, resolveUserShell } from "./shell-env" describe("shell env", () => { test("parseShellEnv reads null-delimited pairs and ignores invalid entries", () => { @@ -27,4 +30,28 @@ describe("shell env", () => { expect(check("C:\\Program Files\\nu.exe")).toBe(true) expect(check("/bin/zsh")).toBe(false) }) + + // The fake shell fails `-il` and holds `-l` until the test releases it, which a blocking probe never lets happen. + test.skipIf(process.platform === "win32")( + "loadShellEnv falls back to -l without blocking the event loop", + async () => { + const root = mkdtempSync(path.join(tmpdir(), "opencode-shell-env-")) + const shell = path.join(root, "shell") + const release = path.join(root, "release") + + try { + writeFileSync( + shell, + `#!/bin/sh\n[ "$1" = "-il" ] && exit 1\nwhile [ ! -e "${release}" ]; do sleep 0.01; done\nFAKE_SHELL_ENV=1 exec /bin/sh -c "$3"\n`, + ) + chmodSync(shell, 0o755) + const env = Effect.runPromise(loadShellEnv(shell).pipe(Effect.provide(NodePath.layer))) + + writeFileSync(release, "") + expect((await env)?.FAKE_SHELL_ENV).toBe("1") + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + ) }) diff --git a/packages/desktop/src/main/service/shell-env.ts b/packages/desktop/src/main/service/shell-env.ts index 7dc4ea4c514..16a37014bc9 100644 --- a/packages/desktop/src/main/service/shell-env.ts +++ b/packages/desktop/src/main/service/shell-env.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process" +import { spawn } from "node:child_process" import { userInfo } from "node:os" import { Effect, Path } from "effect" @@ -6,6 +6,11 @@ const TIMEOUT = 5_000 type Probe = { type: "Loaded"; value: Record } | { type: "Timeout" } | { type: "Unavailable" } +type Run = + | { type: "Exited"; status: number | null; stdout: Buffer } + | { type: "Error"; error: Error } + | { type: "Timeout" } + export function resolveUserShell(envShell: string | undefined, loginShell: string | null | undefined) { const resolvedLoginShell = loginShell && loginShell !== "unknown" ? loginShell : undefined @@ -34,18 +39,32 @@ export function parseShellEnv(out: Buffer) { return env } +// Async so the main thread keeps painting the window and serving renderer assets while a slow rc file runs. +const run = (shell: string, mode: "-il" | "-l") => + Effect.callback((resume) => { + const child = spawn(shell, [mode, "-c", "env -0"], { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }) + + const chunks: Buffer[] = [] + + child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk)) + child.on("error", (error) => resume(Effect.succeed({ type: "Error", error }))) + child.on("close", (status) => resume(Effect.succeed({ type: "Exited", status, stdout: Buffer.concat(chunks) }))) + + return Effect.sync(() => { + if (child.exitCode === null && child.signalCode === null) child.kill() + }) + }).pipe(Effect.timeoutOrElse({ duration: TIMEOUT, orElse: () => Effect.succeed({ type: "Timeout" }) })) + const probe = Effect.fn("ShellEnv.probe")(function* (shell: string, mode: "-il" | "-l") { - const out = spawnSync(shell, [mode, "-c", "env -0"], { - stdio: ["ignore", "pipe", "ignore"], - timeout: TIMEOUT, - windowsHide: true, - }) + const out = yield* run(shell, mode) - const err = out.error as NodeJS.ErrnoException | undefined + if (out.type === "Timeout") return { type: "Timeout" } satisfies Probe - if (err) { - if (err.code === "ETIMEDOUT") return { type: "Timeout" } satisfies Probe - yield* Effect.logWarning(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`) + if (out.type === "Error") { + yield* Effect.logWarning(`[server] Shell env probe failed for ${shell} ${mode}: ${out.error.message}`) return { type: "Unavailable" } satisfies Probe }