Skip to content

Snapshot capture failure (EPERM spawning git) fails the whole step despite the best-effort contract #58685

Open

Description

@JohanTG

Summary

On Windows, Snapshot.capture can fail with
EPERM: operation not permitted, uv_spawn 'C:\Program Files\Git\cmd\git.EXE'. The snapshot is
documented as best-effort and has an error handler, but this failure escapes it and fails the
whole step: the session ends the turn with an idle message and the user sees no error.

Environment

  • opencode version: 2.0.26 (observed); source compared against 2.1.4
  • OS: Windows 10.0.26300 (win32 x64)
  • Terminal: unavailable: TERM_PROGRAM/WT_SESSION are not set (TERM=xterm-256color)
  • Shell: powershell.exe (ComSpec=C:\WINDOWS\system32\cmd.exe)
  • Install/channel: latest
  • Active plugins: @cortexkit/opencode-magic-context@0.48.0

Reproduction

Not reproduced deterministically — EPERM on spawn comes from the OS (Windows security
policy, antivirus/filesystem filter, or process-creation limits). The path is:

  1. Work in a git repository on Windows, keep the snapshot feature enabled.
  2. Trigger a step boundary at a moment when spawning git.exe is refused for the server
    process (EPERM).
  3. The step fails; no tool call is interrupted, no message is produced.

A unit-level repro would be a spawner that throws from spawn() rather than failing with a
typed AppProcess.AppProcessError.

Expected Behavior

A snapshot failure is best-effort by contract (packages/core/src/snapshot.ts:45-47):
log a warning, return undefined, and let the step continue without a snapshot.

Actual Behavior

The error propagates out of Snapshot.capture → SessionStep.attempt → SessionRunner.drain
and ends the step:

timestamp=2026-10-23T11:49:10.570Z level=ERROR run=... message="Failed to drain Session"
cause="Error: EPERM: operation not permitted, uv_spawn 'C:\Program Files\Git\cmd\git.EXE'
    at spawn (unknown)
    at spawn (node:child_process:730:35)
    at spawn (node:child_process:22:39)
    at Cr (chunk-3kgnsp08.js:2:6060)
    at AppProcess.run (chunk-bs2ghvry.js:21:1897)
    at Git.tree.capture (chunk-b7pgr6s2.js:116:2552)
    at Snapshot.capture (chunk-gfcrb6b.js:361:11390)
    at SessionStep.attempt (chunk-gfcr6bw6.js:376:3409)
    at SessionRunner.runStep (chunk-gfcr6bw6.js:376:2507)"

Three milliseconds later the session recorded an idle message (time_created 11:49:10.573Z)
and nothing else. There is no user-visible error and no indication that the turn ended
abnormally.

Additional Context

Why the handler does not catch it

packages/core/src/snapshot.ts:141:

}).pipe(
  Effect.catch((cause) => Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined))),
)

In effect v4 Effect.catch is catchAll: it recovers typed failures (E) only. A spawn
error thrown by child_process arrives as a defect, so the handler never runs. The stack
above starts at spawn (node:child_process:730:35) and the error is a plain Error, not
AppProcess.AppProcessError — consistent with a defect.

2.1.4 does not cover it

  • snapshot.ts in 2.1.4 keeps the same Effect.catch(...) handler at the same place.
  • packages/core/src/git.ts:370-374 (2.1.4) states explicitly: "Only a Git exit is retried,
    never a failure to start Git."

Impact seen

The step ends silently in the middle of a user request; the conversation appears stuck until
the user sends another message.

Frequency in the log of one machine: 42 Failed to drain Session lines, 30 of them in a
single hour
(2026-10-24T04:16Z, 30 consecutive entries within one minute — one session hitting
a local obstacle, not 30 independent cases). After upgrading to 2.1.4: 0 occurrences during
~35 minutes of observation. The sample after the upgrade is too short to call it fixed, and the
relevant code path is unchanged, so this stays open as an observation.

Platform scope

The defect is in the harness code path, not in the OS: any thrown error from spawning git
takes the same route out of the best-effort capture. EPERM is what a Windows endpoint
protection / filesystem filter produces; on Linux the same path is reachable through EACCES
from a noexec mount, SELinux/AppArmor denials, or cgroup limits. So the fix matters on every
platform, while the trigger frequency is far higher on Windows. All measurements below are from
Windows.

Suggested fix direction

Catch defects (Effect.catchCause / Effect.catchAllCause, or Effect.orElseSucceed)
around the snapshot body so the documented best-effort contract holds for thrown spawn
failures too, and surface the failure as a warning in the session log.

Activity

  1. opencode-agent commented on Oct 24, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed analysis. I reproduced it on the v2.0.26 source and on the latest dev (f20760b) using a test, without a real Windows machine.

    The script below makes spawning git throw synchronously with EPERM: operation not permitted, uv_spawn 'git', which matches the error in your stack trace. The first Snapshot.capture() returns a tree ID. The next one, with spawn throwing, does not return undefined as the best-effort contract says. It fails with the EPERM error coming out of Git.tree.capture, which gets past the Effect.catch handler.

    The throw seems to come from launchProcess in packages/util/src/cross-spawn-spawner.ts. It calls cross-spawn without a try/catch, and only async error events become typed errors. On Linux, Bun reports spawn errors such as EACCES asynchronously, so they are handled there. The synchronous throw seems specific to how Bun spawns processes on Windows.

    I didn't run a full session step here. That this then ends the turn silently comes from your log.

    Reproduction script (snapshot-spawn-throw.test.ts)
    // Reproduction for anomalyco/opencode#58685.
    // Run from packages/core of an opencode checkout:
    //   cp snapshot-spawn-throw.test.ts packages/core/test/ && cd packages/core && bun test test/snapshot-spawn-throw.test.ts
    // Simulates a spawn() that throws synchronously (as Bun does on Windows for
    // `EPERM: operation not permitted, uv_spawn 'git.EXE'`) and checks that the
    // best-effort Snapshot.capture returns undefined instead of dying.
    import { mock, describe, expect } from "bun:test"
    import { $ } from "bun"
    import fs from "fs/promises"
    import path from "path"
    import { Cause, Effect, Exit, Layer } from "effect"
    
    let failSpawn = false
    // cross-spawn is a dependency of @opencode/util, which launches every child process through it.
    const crossSpawn = Bun.resolveSync("cross-spawn", path.dirname(Bun.resolveSync("@opencode/util/process", import.meta.dir)))
    const real = (await import(crossSpawn)).default
    mock.module(crossSpawn, () => {
      const launch: any = (cmd: string, args: string[], opts: any) => {
        if (failSpawn && /git/i.test(cmd)) {
          const err: any = new Error(`EPERM: operation not permitted, uv_spawn '${cmd}'`)
          err.code = "EPERM"
          err.syscall = "spawn"
          throw err
        }
        return real(cmd, args, opts)
      }
      launch.spawn = launch
      launch.sync = real.sync
      return { default: launch }
    })
    
    const { AppNodeBuilder } = await import("@opencode/core/effect/app-node-builder")
    const { Global } = await import("@opencode/util/global")
    const { Location } = await import("@opencode/core/location")
    const { AbsolutePath } = await import("@opencode/core/schema")
    const { Snapshot } = await import("@opencode/core/snapshot")
    const { tmpdir } = await import("./fixture/tmpdir")
    const { testEffect } = await import("./lib/effect")
    
    describe("Snapshot best-effort capture (#58685)", () => {
      testEffect(Layer.empty).live("returns undefined when spawning git throws", () =>
        Effect.acquireUseRelease(
          Effect.promise(() => tmpdir()),
          (tmp) =>
            Effect.gen(function* () {
              const project = path.join(tmp.path, "project")
              yield* Effect.promise(async () => {
                await fs.mkdir(project)
                await fs.writeFile(path.join(project, "a.txt"), "one\n")
                await $`git init`.cwd(project).quiet()
              })
              const layer = AppNodeBuilder.build(Snapshot.node, [
                Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
                Global.node.replace(Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })),
              ])
              yield* Effect.gen(function* () {
                const snapshot = yield* Snapshot.Service
                const first = yield* snapshot.capture()
                console.log("first capture (spawn ok):", first)
                expect(first).toBeDefined()
    
                failSpawn = true
                const exit = yield* Effect.exit(snapshot.capture())
                failSpawn = false
                if (Exit.isFailure(exit)) console.log("second capture (spawn throws) DIED:\n" + Cause.pretty(exit.cause))
                else console.log("second capture (spawn throws) returned:", exit.value)
                expect(Exit.isSuccess(exit)).toBe(true)
                if (Exit.isSuccess(exit)) expect(exit.value).toBeUndefined()
              }).pipe(Effect.provide(layer))
            }),
          (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
        ),
      )
    })

    Run it: bun snapshot-spawn-throw.test.ts

  2. Dopetaiga commented on Oct 24, 2026

    @Dopetaiga

    I'd like to take this. Plan: the best-effort handler at snapshot.ts uses Effect.catch, which in Effect v4 recovers typed failures only — a spawn failure from child_process arrives as a defect and sails past it into the step. Add Effect.catchDefect alongside so the documented contract (log a warning, return undefined, keep the step alive) holds for both channels; interruption still propagates. Will verify with a defect-injection repro of the exact handler shape.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions