Skip to content

Commit 07f9f94

Browse files
AndyS77zai-glm-52
andcommitted
fix(BUG-43445): snapshot retry, circuit breaker, transient errors
Co-Authored-By: zai-glm-52 <noreply@ai.local> Agent: @bug-fix Scope: BUG-43445
1 parent d545d8f commit 07f9f94

2 files changed

Lines changed: 116 additions & 19 deletions

File tree

packages/opencode/src/snapshot/index.ts

Lines changed: 94 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,28 @@ const limit = 2 * 1024 * 1024
2525
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
2626
const cfg = ["-c", "core.autocrlf=false", ...core]
2727
const quote = [...cfg, "-c", "core.quotepath=false"]
28+
const retryDelay = Duration.millis(500)
29+
const circuitThreshold = 3
2830
interface GitResult {
2931
readonly code: ChildProcessSpawner.ExitCode
3032
readonly text: string
3133
readonly stderr: string
3234
}
3335

36+
const transientPatterns: readonly string[] = [
37+
"paging file",
38+
"out of memory",
39+
"malloc failed",
40+
"resource temporarily unavailable",
41+
"spawn enomem",
42+
"cannot allocate memory",
43+
]
44+
45+
export function isTransientGitError(stderr: string): boolean {
46+
const lower = stderr.toLowerCase()
47+
return transientPatterns.some((pattern) => lower.includes(pattern))
48+
}
49+
3450
type State = Omit<Interface, "init">
3551

3652
export interface Interface {
@@ -70,33 +86,69 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
7086
worktree: ctx.worktree,
7187
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
7288
vcs: ctx.project.vcs,
89+
consecutiveFailures: 0,
90+
tripped: false,
7391
}
7492

7593
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
7694

95+
const recordFailure = Effect.fnUntraced(function* () {
96+
state.consecutiveFailures += 1
97+
if (state.consecutiveFailures >= circuitThreshold && !state.tripped) {
98+
state.tripped = true
99+
yield* Effect.logError("snapshot circuit breaker tripped — snapshots disabled until next success", {
100+
consecutiveFailures: state.consecutiveFailures,
101+
gitdir: state.gitdir,
102+
})
103+
}
104+
})
105+
106+
const recordSuccess = Effect.fnUntraced(function* () {
107+
if (state.tripped) {
108+
state.tripped = false
109+
yield* Effect.logInfo("snapshot circuit breaker reset — snapshots re-enabled", {
110+
gitdir: state.gitdir,
111+
})
112+
}
113+
state.consecutiveFailures = 0
114+
})
115+
77116
const encodeNulTerminatedPaths = (files: string[]) => files.join("\0") + "\0"
78117
const encodeTopLevelLiteralPathspecs = (files: string[]) =>
79118
encodeNulTerminatedPaths(files.map((file) => `:(top,literal)${file}`))
80119

120+
const execGit = (cmd: string[], opts?: { cwd?: string; env?: Record<string, string>; stdin?: string }) =>
121+
appProcess
122+
.run(ChildProcess.make("git", cmd, { cwd: opts?.cwd, env: opts?.env, extendEnv: true }), {
123+
stdin: opts?.stdin,
124+
})
125+
.pipe(
126+
Effect.map((result) => ({
127+
code: ChildProcessSpawner.ExitCode(result.exitCode),
128+
text: result.stdout.toString("utf8"),
129+
stderr: result.stderr.toString("utf8"),
130+
})),
131+
Effect.catch((err) =>
132+
Effect.succeed({
133+
code: ChildProcessSpawner.ExitCode(1),
134+
text: "",
135+
stderr: err instanceof Error ? err.message : String(err),
136+
}),
137+
),
138+
)
139+
81140
const git = Effect.fnUntraced(
82141
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string>; stdin?: string }) {
83-
const result = yield* appProcess.run(
84-
ChildProcess.make("git", cmd, { cwd: opts?.cwd, env: opts?.env, extendEnv: true }),
85-
{ stdin: opts?.stdin },
86-
)
87-
return {
88-
code: ChildProcessSpawner.ExitCode(result.exitCode),
89-
text: result.stdout.toString("utf8"),
90-
stderr: result.stderr.toString("utf8"),
91-
} satisfies GitResult
142+
const first = yield* execGit(cmd, opts)
143+
if (first.code !== 0 && isTransientGitError(first.stderr)) {
144+
yield* Effect.logWarning("snapshot git transient error, retrying", {
145+
stderr: first.stderr,
146+
})
147+
yield* Effect.sleep(retryDelay)
148+
return yield* execGit(cmd, opts)
149+
}
150+
return first
92151
},
93-
Effect.catch((err) =>
94-
Effect.succeed({
95-
code: ChildProcessSpawner.ExitCode(1),
96-
text: "",
97-
stderr: err instanceof Error ? err.message : String(err),
98-
}),
99-
),
100152
)
101153

102154
const ignore = Effect.fnUntraced(function* (files: string[]) {
@@ -153,6 +205,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
153205
},
154206
)
155207
if (result.code === 0) return
208+
yield* recordFailure()
156209
yield* Effect.logWarning("failed to add snapshot files", {
157210
exitCode: result.code,
158211
stderr: result.stderr,
@@ -233,6 +286,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
233286
})
234287

235288
const add = Effect.fnUntraced(function* () {
289+
if (state.tripped) return false
236290
yield* sync()
237291
const [diff, other] = yield* Effect.all(
238292
[
@@ -246,13 +300,14 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
246300
{ concurrency: 2 },
247301
)
248302
if (diff.code !== 0 || other.code !== 0) {
303+
yield* recordFailure()
249304
yield* Effect.logWarning("failed to list snapshot files", {
250305
diffCode: diff.code,
251306
diffStderr: diff.stderr,
252307
otherCode: other.code,
253308
otherStderr: other.stderr,
254309
})
255-
return
310+
return false
256311
}
257312

258313
const tracked = diff.text.split("\0").filter(Boolean)
@@ -293,8 +348,9 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
293348
)
294349
const block = new Set(untracked.filter((item) => large.has(item)))
295350
yield* sync(Array.from(block))
296-
// Stage only the allowed candidate paths so snapshot updates stay scoped.
297351
yield* stage(allow.filter((item) => !block.has(item)))
352+
yield* recordSuccess()
353+
return true
298354
})
299355

300356
const cleanup = Effect.fnUntraced(function* () {
@@ -319,6 +375,12 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
319375
return yield* locked(
320376
Effect.gen(function* () {
321377
if (!(yield* enabled())) return
378+
if (state.tripped) {
379+
yield* Effect.logWarning("snapshot skipped — circuit breaker tripped", {
380+
consecutiveFailures: state.consecutiveFailures,
381+
})
382+
return
383+
}
322384
const existed = yield* exists(state.gitdir)
323385
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
324386
if (!existed) {
@@ -337,8 +399,18 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
337399
yield* seed()
338400
yield* Effect.logInfo("initialized")
339401
}
340-
yield* add()
402+
const ok = yield* add()
403+
if (!ok) return
341404
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
405+
if (result.code !== 0) {
406+
yield* recordFailure()
407+
yield* Effect.logWarning("failed to write snapshot tree", {
408+
exitCode: result.code,
409+
stderr: result.stderr,
410+
})
411+
return
412+
}
413+
yield* recordSuccess()
342414
const hash = result.text.trim()
343415
yield* Effect.logInfo("tracking", { hash, cwd: state.directory, git: state.gitdir })
344416
return hash
@@ -349,6 +421,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
349421
const patch = Effect.fnUntraced(function* (hash: string) {
350422
return yield* locked(
351423
Effect.gen(function* () {
424+
if (state.tripped) return { hash, files: [] }
352425
yield* add()
353426
const result = yield* git(
354427
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
@@ -526,6 +599,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
526599
const diff = Effect.fnUntraced(function* (hash: string) {
527600
return yield* locked(
528601
Effect.gen(function* () {
602+
if (state.tripped) return ""
529603
yield* add()
530604
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
531605
cwd: state.worktree,
@@ -546,6 +620,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
546620
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
547621
return yield* locked(
548622
Effect.gen(function* () {
623+
if (state.tripped) return []
549624
type Row = {
550625
file: string
551626
status: "added" | "deleted" | "modified"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from "bun:test"
2+
import { isTransientGitError } from "../../src/snapshot"
3+
4+
describe("isTransientGitError", () => {
5+
const cases: Array<[string, boolean]> = [
6+
["error launching git: The paging file is too small for this operation to complete.", true],
7+
["fatal: Out of memory, malloc failed (tried to allocate 1048576 bytes)", true],
8+
["fatal: Out of memory, (tried to allocate 4241 wchar_t's)", true],
9+
["error launching git: resource temporarily unavailable", true],
10+
["spawn ENOMEM", true],
11+
["cannot allocate memory", true],
12+
["fatal: not a git repository", false],
13+
["error: pathspec 'foo' did not match any file(s) known to git", false],
14+
["", false],
15+
]
16+
17+
for (const [stderr, expected] of cases) {
18+
it(`"${stderr.slice(0, 40)}..." -> ${expected}`, () => {
19+
expect(isTransientGitError(stderr)).toBe(expected)
20+
})
21+
}
22+
})

0 commit comments

Comments
 (0)