fix: resolve ACP hanging indefinitely in thinking state on Windows (#13222)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
@@ -2,7 +2,6 @@ import z from "zod"
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { $ } from "bun"
|
|
||||||
import { Storage } from "../storage/storage"
|
import { Storage } from "../storage/storage"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
@@ -13,6 +12,7 @@ import { BusEvent } from "@/bus/bus-event"
|
|||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
|
import { git } from "../util/git"
|
||||||
|
|
||||||
export namespace Project {
|
export namespace Project {
|
||||||
const log = Log.create({ service: "project" })
|
const log = Log.create({ service: "project" })
|
||||||
@@ -55,15 +55,15 @@ export namespace Project {
|
|||||||
|
|
||||||
const { id, sandbox, worktree, vcs } = await iife(async () => {
|
const { id, sandbox, worktree, vcs } = await iife(async () => {
|
||||||
const matches = Filesystem.up({ targets: [".git"], start: directory })
|
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()
|
await matches.return()
|
||||||
if (git) {
|
if (dotgit) {
|
||||||
let sandbox = path.dirname(git)
|
let sandbox = path.dirname(dotgit)
|
||||||
|
|
||||||
const gitBinary = Bun.which("git")
|
const gitBinary = Bun.which("git")
|
||||||
|
|
||||||
// cached id calculation
|
// cached id calculation
|
||||||
let id = await Bun.file(path.join(git, "opencode"))
|
let id = await Bun.file(path.join(dotgit, "opencode"))
|
||||||
.text()
|
.text()
|
||||||
.then((x) => x.trim())
|
.then((x) => x.trim())
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
@@ -79,13 +79,11 @@ export namespace Project {
|
|||||||
|
|
||||||
// generate id from root commit
|
// generate id from root commit
|
||||||
if (!id) {
|
if (!id) {
|
||||||
const roots = await $`git rev-list --max-parents=0 --all`
|
const roots = await git(["rev-list", "--max-parents=0", "--all"], {
|
||||||
.quiet()
|
cwd: sandbox,
|
||||||
.nothrow()
|
})
|
||||||
.cwd(sandbox)
|
.then(async (result) =>
|
||||||
.text()
|
(await result.text())
|
||||||
.then((x) =>
|
|
||||||
x
|
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((x) => x.trim())
|
.map((x) => x.trim())
|
||||||
@@ -104,7 +102,7 @@ export namespace Project {
|
|||||||
|
|
||||||
id = roots[0]
|
id = roots[0]
|
||||||
if (id) {
|
if (id) {
|
||||||
void Bun.file(path.join(git, "opencode"))
|
void Bun.file(path.join(dotgit, "opencode"))
|
||||||
.write(id)
|
.write(id)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
@@ -119,12 +117,10 @@ export namespace Project {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const top = await $`git rev-parse --show-toplevel`
|
const top = await git(["rev-parse", "--show-toplevel"], {
|
||||||
.quiet()
|
cwd: sandbox,
|
||||||
.nothrow()
|
})
|
||||||
.cwd(sandbox)
|
.then(async (result) => path.resolve(sandbox, (await result.text()).trim()))
|
||||||
.text()
|
|
||||||
.then((x) => path.resolve(sandbox, x.trim()))
|
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
|
||||||
if (!top) {
|
if (!top) {
|
||||||
@@ -138,13 +134,11 @@ export namespace Project {
|
|||||||
|
|
||||||
sandbox = top
|
sandbox = top
|
||||||
|
|
||||||
const worktree = await $`git rev-parse --git-common-dir`
|
const worktree = await git(["rev-parse", "--git-common-dir"], {
|
||||||
.quiet()
|
cwd: sandbox,
|
||||||
.nothrow()
|
})
|
||||||
.cwd(sandbox)
|
.then(async (result) => {
|
||||||
.text()
|
const dirname = path.dirname((await result.text()).trim())
|
||||||
.then((x) => {
|
|
||||||
const dirname = path.dirname(x.trim())
|
|
||||||
if (dirname === ".") return sandbox
|
if (dirname === ".") return sandbox
|
||||||
return dirname
|
return dirname
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { $ } from "bun"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { Flag } from "../flag/flag"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
@@ -23,7 +24,7 @@ export namespace Snapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanup() {
|
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()
|
const cfg = await Config.get()
|
||||||
if (cfg.snapshot === false) return
|
if (cfg.snapshot === false) return
|
||||||
const git = gitdir()
|
const git = gitdir()
|
||||||
@@ -48,7 +49,7 @@ export namespace Snapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function track() {
|
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()
|
const cfg = await Config.get()
|
||||||
if (cfg.snapshot === false) return
|
if (cfg.snapshot === false) return
|
||||||
const git = gitdir()
|
const git = gitdir()
|
||||||
|
|||||||
64
packages/opencode/src/util/git.ts
Normal file
64
packages/opencode/src/util/git.ts
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,54 +8,45 @@ import { tmpdir } from "../fixture/fixture"
|
|||||||
|
|
||||||
Log.init({ print: false })
|
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"
|
type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
|
||||||
let mode: Mode = "none"
|
let mode: Mode = "none"
|
||||||
|
|
||||||
function render(parts: TemplateStringsArray, vals: unknown[]) {
|
mock.module("../../src/util/git", () => ({
|
||||||
return parts.reduce((acc, part, i) => `${acc}${part}${i < vals.length ? String(vals[i]) : ""}`, "")
|
git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => {
|
||||||
}
|
const cmd = ["git", ...args].join(" ")
|
||||||
|
|
||||||
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()
|
|
||||||
if (
|
if (
|
||||||
mode === "rev-list-fail" &&
|
mode === "rev-list-fail" &&
|
||||||
cmd.includes("git rev-list") &&
|
cmd.includes("git rev-list") &&
|
||||||
cmd.includes("--max-parents=0") &&
|
cmd.includes("--max-parents=0") &&
|
||||||
cmd.includes("--all")
|
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")) {
|
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")) {
|
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)
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user