Skip to content

Commit 991496a

Browse files
ASidorenkoCodeclaudeHonarekram1-node
authored
fix: resolve ACP hanging indefinitely in thinking state on Windows (anomalyco#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>
1 parent 76db218 commit 991496a

4 files changed

Lines changed: 112 additions & 62 deletions

File tree

packages/opencode/src/project/project.ts

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import z from "zod"
22
import fs from "fs/promises"
33
import { Filesystem } from "../util/filesystem"
44
import path from "path"
5-
import { $ } from "bun"
65
import { Storage } from "../storage/storage"
76
import { Log } from "../util/log"
87
import { Flag } from "@/flag/flag"
@@ -13,6 +12,7 @@ import { BusEvent } from "@/bus/bus-event"
1312
import { iife } from "@/util/iife"
1413
import { GlobalBus } from "@/bus/global"
1514
import { existsSync } from "fs"
15+
import { git } from "../util/git"
1616

1717
export namespace Project {
1818
const log = Log.create({ service: "project" })
@@ -55,15 +55,15 @@ export namespace Project {
5555

5656
const { id, sandbox, worktree, vcs } = await iife(async () => {
5757
const matches = Filesystem.up({ targets: [".git"], start: directory })
58-
const git = await matches.next().then((x) => x.value)
58+
const dotgit = await matches.next().then((x) => x.value)
5959
await matches.return()
60-
if (git) {
61-
let sandbox = path.dirname(git)
60+
if (dotgit) {
61+
let sandbox = path.dirname(dotgit)
6262

6363
const gitBinary = Bun.which("git")
6464

6565
// cached id calculation
66-
let id = await Bun.file(path.join(git, "opencode"))
66+
let id = await Bun.file(path.join(dotgit, "opencode"))
6767
.text()
6868
.then((x) => x.trim())
6969
.catch(() => undefined)
@@ -79,13 +79,11 @@ export namespace Project {
7979

8080
// generate id from root commit
8181
if (!id) {
82-
const roots = await $`git rev-list --max-parents=0 --all`
83-
.quiet()
84-
.nothrow()
85-
.cwd(sandbox)
86-
.text()
87-
.then((x) =>
88-
x
82+
const roots = await git(["rev-list", "--max-parents=0", "--all"], {
83+
cwd: sandbox,
84+
})
85+
.then(async (result) =>
86+
(await result.text())
8987
.split("\n")
9088
.filter(Boolean)
9189
.map((x) => x.trim())
@@ -104,7 +102,7 @@ export namespace Project {
104102

105103
id = roots[0]
106104
if (id) {
107-
void Bun.file(path.join(git, "opencode"))
105+
void Bun.file(path.join(dotgit, "opencode"))
108106
.write(id)
109107
.catch(() => undefined)
110108
}
@@ -119,12 +117,10 @@ export namespace Project {
119117
}
120118
}
121119

122-
const top = await $`git rev-parse --show-toplevel`
123-
.quiet()
124-
.nothrow()
125-
.cwd(sandbox)
126-
.text()
127-
.then((x) => path.resolve(sandbox, x.trim()))
120+
const top = await git(["rev-parse", "--show-toplevel"], {
121+
cwd: sandbox,
122+
})
123+
.then(async (result) => path.resolve(sandbox, (await result.text()).trim()))
128124
.catch(() => undefined)
129125

130126
if (!top) {
@@ -138,13 +134,11 @@ export namespace Project {
138134

139135
sandbox = top
140136

141-
const worktree = await $`git rev-parse --git-common-dir`
142-
.quiet()
143-
.nothrow()
144-
.cwd(sandbox)
145-
.text()
146-
.then((x) => {
147-
const dirname = path.dirname(x.trim())
137+
const worktree = await git(["rev-parse", "--git-common-dir"], {
138+
cwd: sandbox,
139+
})
140+
.then(async (result) => {
141+
const dirname = path.dirname((await result.text()).trim())
148142
if (dirname === ".") return sandbox
149143
return dirname
150144
})

packages/opencode/src/snapshot/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { $ } from "bun"
22
import path from "path"
33
import fs from "fs/promises"
44
import { Log } from "../util/log"
5+
import { Flag } from "../flag/flag"
56
import { Global } from "../global"
67
import z from "zod"
78
import { Config } from "../config/config"
@@ -23,7 +24,7 @@ export namespace Snapshot {
2324
}
2425

2526
export async function cleanup() {
26-
if (Instance.project.vcs !== "git") return
27+
if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return
2728
const cfg = await Config.get()
2829
if (cfg.snapshot === false) return
2930
const git = gitdir()
@@ -48,7 +49,7 @@ export namespace Snapshot {
4849
}
4950

5051
export async function track() {
51-
if (Instance.project.vcs !== "git") return
52+
if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return
5253
const cfg = await Config.get()
5354
if (cfg.snapshot === false) return
5455
const git = gitdir()

packages/opencode/src/util/git.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { $ } from "bun"
2+
import { Flag } from "../flag/flag"
3+
4+
export interface GitResult {
5+
exitCode: number
6+
text(): string | Promise<string>
7+
stdout: Buffer | ReadableStream<Uint8Array>
8+
stderr: Buffer | ReadableStream<Uint8Array>
9+
}
10+
11+
/**
12+
* Run a git command.
13+
*
14+
* Uses Bun's lightweight `$` shell by default. When the process is running
15+
* as an ACP client, child processes inherit the parent's stdin pipe which
16+
* carries protocol data – on Windows this causes git to deadlock. In that
17+
* case we fall back to `Bun.spawn` with `stdin: "ignore"`.
18+
*/
19+
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
20+
if (Flag.OPENCODE_CLIENT === "acp") {
21+
try {
22+
const proc = Bun.spawn(["git", ...args], {
23+
stdin: "ignore",
24+
stdout: "pipe",
25+
stderr: "pipe",
26+
cwd: opts.cwd,
27+
env: opts.env ? { ...process.env, ...opts.env } : process.env,
28+
})
29+
// Read output concurrently with exit to avoid pipe buffer deadlock
30+
const [exitCode, stdout, stderr] = await Promise.all([
31+
proc.exited,
32+
new Response(proc.stdout).arrayBuffer(),
33+
new Response(proc.stderr).arrayBuffer(),
34+
])
35+
const stdoutBuf = Buffer.from(stdout)
36+
const stderrBuf = Buffer.from(stderr)
37+
return {
38+
exitCode,
39+
text: () => stdoutBuf.toString(),
40+
stdout: stdoutBuf,
41+
stderr: stderrBuf,
42+
}
43+
} catch (error) {
44+
const stderr = Buffer.from(error instanceof Error ? error.message : String(error))
45+
return {
46+
exitCode: 1,
47+
text: () => "",
48+
stdout: Buffer.alloc(0),
49+
stderr,
50+
}
51+
}
52+
}
53+
54+
const env = opts.env ? { ...process.env, ...opts.env } : undefined
55+
let cmd = $`git ${args}`.quiet().nothrow().cwd(opts.cwd)
56+
if (env) cmd = cmd.env(env)
57+
const result = await cmd
58+
return {
59+
exitCode: result.exitCode,
60+
text: () => result.text(),
61+
stdout: result.stdout,
62+
stderr: result.stderr,
63+
}
64+
}

packages/opencode/test/project/project.test.ts

Lines changed: 25 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,54 +8,45 @@ import { tmpdir } from "../fixture/fixture"
88

99
Log.init({ print: false })
1010

11-
const bunModule = await import("bun")
11+
const gitModule = await import("../../src/util/git")
12+
const originalGit = gitModule.git
13+
1214
type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
1315
let mode: Mode = "none"
1416

15-
function render(parts: TemplateStringsArray, vals: unknown[]) {
16-
return parts.reduce((acc, part, i) => `${acc}${part}${i < vals.length ? String(vals[i]) : ""}`, "")
17-
}
18-
19-
function fakeShell(output: { exitCode: number; stdout: string; stderr: string }) {
20-
const result = {
21-
exitCode: output.exitCode,
22-
stdout: Buffer.from(output.stdout),
23-
stderr: Buffer.from(output.stderr),
24-
text: async () => output.stdout,
25-
}
26-
const shell = {
27-
quiet: () => shell,
28-
nothrow: () => shell,
29-
cwd: () => shell,
30-
env: () => shell,
31-
text: async () => output.stdout,
32-
then: (onfulfilled: (value: typeof result) => unknown, onrejected?: (reason: unknown) => unknown) =>
33-
Promise.resolve(result).then(onfulfilled, onrejected),
34-
catch: (onrejected: (reason: unknown) => unknown) => Promise.resolve(result).catch(onrejected),
35-
finally: (onfinally: (() => void) | undefined | null) => Promise.resolve(result).finally(onfinally),
36-
}
37-
return shell
38-
}
39-
40-
mock.module("bun", () => ({
41-
...bunModule,
42-
$: (parts: TemplateStringsArray, ...vals: unknown[]) => {
43-
const cmd = render(parts, vals).replaceAll(",", " ").replace(/\s+/g, " ").trim()
17+
mock.module("../../src/util/git", () => ({
18+
git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => {
19+
const cmd = ["git", ...args].join(" ")
4420
if (
4521
mode === "rev-list-fail" &&
4622
cmd.includes("git rev-list") &&
4723
cmd.includes("--max-parents=0") &&
4824
cmd.includes("--all")
4925
) {
50-
return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
26+
return Promise.resolve({
27+
exitCode: 128,
28+
text: () => Promise.resolve(""),
29+
stdout: Buffer.from(""),
30+
stderr: Buffer.from("fatal"),
31+
})
5132
}
5233
if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) {
53-
return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
34+
return Promise.resolve({
35+
exitCode: 128,
36+
text: () => Promise.resolve(""),
37+
stdout: Buffer.from(""),
38+
stderr: Buffer.from("fatal"),
39+
})
5440
}
5541
if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) {
56-
return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
42+
return Promise.resolve({
43+
exitCode: 128,
44+
text: () => Promise.resolve(""),
45+
stdout: Buffer.from(""),
46+
stderr: Buffer.from("fatal"),
47+
})
5748
}
58-
return (bunModule.$ as any)(parts, ...vals)
49+
return originalGit(args, opts)
5950
},
6051
}))
6152

0 commit comments

Comments
 (0)