Visitar URL original
fix(desktop): stop freezing the main process during the login shell env probe by rekram1-node · Pull Request #63208 · anomalyco/opencode · GitHub
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
29 changes: 28 additions & 1 deletion packages/desktop/src/main/service/shell-env.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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 })
}
},
)
})
39 changes: 29 additions & 10 deletions packages/desktop/src/main/service/shell-env.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { spawnSync } from "node:child_process"
import { spawn } from "node:child_process"
import { userInfo } from "node:os"
import { Effect, Path } from "effect"

const TIMEOUT = 5_000

type Probe = { type: "Loaded"; value: Record<string, string> } | { 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

Expand Down Expand Up @@ -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<Run>((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<Run>({ 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
}
Expand Down
Loading