Kaynağa Gözat

fix: resolve ACP hanging indefinitely in thinking state on Windows (#13222)

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: LukeParkerDev <[email protected]>
Co-authored-by: Aiden Cline <[email protected]>
Co-authored-by: Aiden Cline <[email protected]>
projectArtur 2 ay önce
ebeveyn
işleme
991496a753

+ 20 - 26
packages/opencode/src/project/project.ts

@@ -2,7 +2,6 @@ import z from "zod"
 import fs from "fs/promises"
 import { Filesystem } from "../util/filesystem"
 import path from "path"
-import { $ } from "bun"
 import { Storage } from "../storage/storage"
 import { Log } from "../util/log"
 import { Flag } from "@/flag/flag"
@@ -13,6 +12,7 @@ import { BusEvent } from "@/bus/bus-event"
 import { iife } from "@/util/iife"
 import { GlobalBus } from "@/bus/global"
 import { existsSync } from "fs"
+import { git } from "../util/git"
 
 export namespace Project {
   const log = Log.create({ service: "project" })
@@ -55,15 +55,15 @@ export namespace Project {
 
     const { id, sandbox, worktree, vcs } = await iife(async () => {
       const matches = Filesystem.up({ targets: [".git"], start: directory })
-      const git = await matches.next().then((x) => x.value)
+      const dotgit = await matches.next().then((x) => x.value)
       await matches.return()
-      if (git) {
-        let sandbox = path.dirname(git)
+      if (dotgit) {
+        let sandbox = path.dirname(dotgit)
 
         const gitBinary = Bun.which("git")
 
         // cached id calculation
-        let id = await Bun.file(path.join(git, "opencode"))
+        let id = await Bun.file(path.join(dotgit, "opencode"))
           .text()
           .then((x) => x.trim())
           .catch(() => undefined)
@@ -79,13 +79,11 @@ export namespace Project {
 
         // generate id from root commit
         if (!id) {
-          const roots = await $`git rev-list --max-parents=0 --all`
-            .quiet()
-            .nothrow()
-            .cwd(sandbox)
-            .text()
-            .then((x) =>
-              x
+          const roots = await git(["rev-list", "--max-parents=0", "--all"], {
+            cwd: sandbox,
+          })
+            .then(async (result) =>
+              (await result.text())
                 .split("\n")
                 .filter(Boolean)
                 .map((x) => x.trim())
@@ -104,7 +102,7 @@ export namespace Project {
 
           id = roots[0]
           if (id) {
-            void Bun.file(path.join(git, "opencode"))
+            void Bun.file(path.join(dotgit, "opencode"))
               .write(id)
               .catch(() => undefined)
           }
@@ -119,12 +117,10 @@ export namespace Project {
           }
         }
 
-        const top = await $`git rev-parse --show-toplevel`
-          .quiet()
-          .nothrow()
-          .cwd(sandbox)
-          .text()
-          .then((x) => path.resolve(sandbox, x.trim()))
+        const top = await git(["rev-parse", "--show-toplevel"], {
+          cwd: sandbox,
+        })
+          .then(async (result) => path.resolve(sandbox, (await result.text()).trim()))
           .catch(() => undefined)
 
         if (!top) {
@@ -138,13 +134,11 @@ export namespace Project {
 
         sandbox = top
 
-        const worktree = await $`git rev-parse --git-common-dir`
-          .quiet()
-          .nothrow()
-          .cwd(sandbox)
-          .text()
-          .then((x) => {
-            const dirname = path.dirname(x.trim())
+        const worktree = await git(["rev-parse", "--git-common-dir"], {
+          cwd: sandbox,
+        })
+          .then(async (result) => {
+            const dirname = path.dirname((await result.text()).trim())
             if (dirname === ".") return sandbox
             return dirname
           })

+ 3 - 2
packages/opencode/src/snapshot/index.ts

@@ -2,6 +2,7 @@ import { $ } from "bun"
 import path from "path"
 import fs from "fs/promises"
 import { Log } from "../util/log"
+import { Flag } from "../flag/flag"
 import { Global } from "../global"
 import z from "zod"
 import { Config } from "../config/config"
@@ -23,7 +24,7 @@ export namespace Snapshot {
   }
 
   export async function cleanup() {
-    if (Instance.project.vcs !== "git") return
+    if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return
     const cfg = await Config.get()
     if (cfg.snapshot === false) return
     const git = gitdir()
@@ -48,7 +49,7 @@ export namespace Snapshot {
   }
 
   export async function track() {
-    if (Instance.project.vcs !== "git") return
+    if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return
     const cfg = await Config.get()
     if (cfg.snapshot === false) return
     const git = gitdir()

+ 64 - 0
packages/opencode/src/util/git.ts

@@ -0,0 +1,64 @@
+import { $ } from "bun"
+import { Flag } from "../flag/flag"
+
+export interface GitResult {
+  exitCode: number
+  text(): string | Promise<string>
+  stdout: Buffer | ReadableStream<Uint8Array>
+  stderr: Buffer | ReadableStream<Uint8Array>
+}
+
+/**
+ * Run a git command.
+ *
+ * Uses Bun's lightweight `$` shell by default.  When the process is running
+ * as an ACP client, child processes inherit the parent's stdin pipe which
+ * carries protocol data – on Windows this causes git to deadlock.  In that
+ * case we fall back to `Bun.spawn` with `stdin: "ignore"`.
+ */
+export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
+  if (Flag.OPENCODE_CLIENT === "acp") {
+    try {
+      const proc = Bun.spawn(["git", ...args], {
+        stdin: "ignore",
+        stdout: "pipe",
+        stderr: "pipe",
+        cwd: opts.cwd,
+        env: opts.env ? { ...process.env, ...opts.env } : process.env,
+      })
+      // Read output concurrently with exit to avoid pipe buffer deadlock
+      const [exitCode, stdout, stderr] = await Promise.all([
+        proc.exited,
+        new Response(proc.stdout).arrayBuffer(),
+        new Response(proc.stderr).arrayBuffer(),
+      ])
+      const stdoutBuf = Buffer.from(stdout)
+      const stderrBuf = Buffer.from(stderr)
+      return {
+        exitCode,
+        text: () => stdoutBuf.toString(),
+        stdout: stdoutBuf,
+        stderr: stderrBuf,
+      }
+    } catch (error) {
+      const stderr = Buffer.from(error instanceof Error ? error.message : String(error))
+      return {
+        exitCode: 1,
+        text: () => "",
+        stdout: Buffer.alloc(0),
+        stderr,
+      }
+    }
+  }
+
+  const env = opts.env ? { ...process.env, ...opts.env } : undefined
+  let cmd = $`git ${args}`.quiet().nothrow().cwd(opts.cwd)
+  if (env) cmd = cmd.env(env)
+  const result = await cmd
+  return {
+    exitCode: result.exitCode,
+    text: () => result.text(),
+    stdout: result.stdout,
+    stderr: result.stderr,
+  }
+}

+ 25 - 34
packages/opencode/test/project/project.test.ts

@@ -8,54 +8,45 @@ import { tmpdir } from "../fixture/fixture"
 
 Log.init({ print: false })
 
-const bunModule = await import("bun")
+const gitModule = await import("../../src/util/git")
+const originalGit = gitModule.git
+
 type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
 let mode: Mode = "none"
 
-function render(parts: TemplateStringsArray, vals: unknown[]) {
-  return parts.reduce((acc, part, i) => `${acc}${part}${i < vals.length ? String(vals[i]) : ""}`, "")
-}
-
-function fakeShell(output: { exitCode: number; stdout: string; stderr: string }) {
-  const result = {
-    exitCode: output.exitCode,
-    stdout: Buffer.from(output.stdout),
-    stderr: Buffer.from(output.stderr),
-    text: async () => output.stdout,
-  }
-  const shell = {
-    quiet: () => shell,
-    nothrow: () => shell,
-    cwd: () => shell,
-    env: () => shell,
-    text: async () => output.stdout,
-    then: (onfulfilled: (value: typeof result) => unknown, onrejected?: (reason: unknown) => unknown) =>
-      Promise.resolve(result).then(onfulfilled, onrejected),
-    catch: (onrejected: (reason: unknown) => unknown) => Promise.resolve(result).catch(onrejected),
-    finally: (onfinally: (() => void) | undefined | null) => Promise.resolve(result).finally(onfinally),
-  }
-  return shell
-}
-
-mock.module("bun", () => ({
-  ...bunModule,
-  $: (parts: TemplateStringsArray, ...vals: unknown[]) => {
-    const cmd = render(parts, vals).replaceAll(",", " ").replace(/\s+/g, " ").trim()
+mock.module("../../src/util/git", () => ({
+  git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => {
+    const cmd = ["git", ...args].join(" ")
     if (
       mode === "rev-list-fail" &&
       cmd.includes("git rev-list") &&
       cmd.includes("--max-parents=0") &&
       cmd.includes("--all")
     ) {
-      return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
+      return Promise.resolve({
+        exitCode: 128,
+        text: () => Promise.resolve(""),
+        stdout: Buffer.from(""),
+        stderr: Buffer.from("fatal"),
+      })
     }
     if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) {
-      return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
+      return Promise.resolve({
+        exitCode: 128,
+        text: () => Promise.resolve(""),
+        stdout: Buffer.from(""),
+        stderr: Buffer.from("fatal"),
+      })
     }
     if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) {
-      return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
+      return Promise.resolve({
+        exitCode: 128,
+        text: () => Promise.resolve(""),
+        stdout: Buffer.from(""),
+        stderr: Buffer.from("fatal"),
+      })
     }
-    return (bunModule.$ as any)(parts, ...vals)
+    return originalGit(args, opts)
   },
 }))