Skip to content

core: ripgrep auto-download fails silently behind Windows system proxy and never recovers until restart #51022

Open

Description

@ganfabo123-cmyk

Summary

On Windows, opencode's on-demand ripgrep download does not honor the system proxy configured in Windows Internet Settings (WinINET registry) — it only respects HTTP_PROXY/HTTPS_PROXY env vars. On networks where GitHub is only reachable through that proxy, the download fails, the failure is reported to the model only as the generic ripgrep execution failed (nothing in the server log after the downloading ripgrep INFO line), and the resolver never recovers for the lifetime of the server process: grep/glob/find stay broken until a full restart, even after a valid rg.exe is manually placed where the resolver looks.

Environment

  • opencode version: v2.0.12 (npm global @opencode/cli)
  • OS: Windows_NT 10.0.26200 (win32 x64)
  • Terminal: VS Code (TERM_PROGRAM=vscode)
  • Shell: agent shell runs PowerShell 5.1 (ComSpec is cmd.exe)
  • Install/channel: npm, release
  • Active plugins: none configured

Reproduction

  1. Windows machine where github.com is only reachable via a local proxy configured in Windows Internet Settings (WinINET registry: ProxyEnable=1, ProxyServer=127.0.0.1:7890), with no HTTP_PROXY/HTTPS_PROXY env vars set. (netsh winhttp show proxy shows direct; Invoke-WebRequest to the same release URL returns HTTP 200 because .NET honors the registry proxy — the network itself is fine.)
  2. Ensure rg is not on PATH and %USERPROFILE%\.cache\opencode\bin\rg.exe does not exist.
  3. Start opencode and trigger the grep tool.
  4. Server log (~/.local/share/opencode/log/opencode.log) shows:
    level=INFO message="downloading ripgrep" url=https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-pc-windows-msvc.zip
    
    …and then nothing: no error entry, %USERPROFILE%\.cache\opencode\bin stays empty.
  5. The tool result returned to the model is only: ripgrep execution failed — no hint that the download was the failing step.
  6. Manually place a known-good rg.exe (verified runnable directly and spawnable from Node child_process) at %USERPROFILE%\.cache\opencode\bin\rg.exe and call grep again:
    • still ripgrep execution failed
    • no new downloading ripgrep entry (resolver does not re-run)
    • no process spawn for rg
  7. Only after fully exiting opencode (including the serve --service backend) and relaunching does grep work — the freshly placed binary is picked up on the first call, no network needed.

Expected Behavior

  • The download should either honor the OS proxy configuration or at minimum honor HTTPS_PROXY-style env vars consistently, and fail loudly (the underlying cause should reach the log / tool error).
  • A resolver failure should not persist for the process lifetime: subsequent calls should retry, and a manually-provided binary on disk should be picked up without a restart.

Actual Behavior

After the failed/hung download, every subsequent grep/glob call fails instantly with ripgrep execution failed. No retry is logged, no rg process is ever spawned, and manually fixing the binary on disk has no effect until the app is restarted. This made the root cause (proxy-blind download) very hard to diagnose: the error message points at "ripgrep execution" while the real failure is a network fetch several layers earlier.

Additional Context

  • Affected code (v2.0.12):
    • packages/core/src/ripgrep/binary.ts — resolver wrapped in Effect.cached: which() → <cache>/bin/rg.exe → download via effect HttpClient (no proxy options, no visible timeout). Effect.cached explains why the failed/pending computation is never retried for the process lifetime.
    • packages/core/src/ripgrep.ts — every non-Error/InvalidPatternError failure is mapped to failure("ripgrep execution failed", cause), and the cause never reaches the model or the log.
  • Timeline observed in the log: one downloading ripgrep entry per fresh failure on day 1 and day 2 (retries did happen while each fetch failed fast), then after the final attempt no further attempts were ever logged for that server process — consistent with the cached resolver holding a failed/pending result.
  • Workaround that works: download the ripgrep zip out-of-band (through the system proxy), extract, and copy rg.exe to %USERPROFILE%\.cache\opencode\bin\rg.exe, then restart opencode. Alternatively put rg.exe on PATH before first use.
  • Possible directions:
    • pass proxy config / honor env+OS proxy in the ripgrep download client, and add a fetch timeout
    • log the download failure at ERROR level and include the underlying cause in the mapped tool error
    • invalidate the cached resolver on failure (or re-check the disk path on every call) so a manually-placed binary is adopted without a restart

Activity

  1. aron-intframe commented on Sep 24, 2026

    @aron-intframe

    Confirmed the "never recovers" half at the source level, and it is not proxy- or Windows-specific: Effect.cached at packages/core/src/ripgrep/binary.ts:92 memoizes the failure, so the resolver body never runs a second time and the <cache>/bin/rg check at line 98 is never re-evaluated.

    I mirrored the resolver (same order: which() -> <cache>/bin/rg -> logInfo -> http.execute) against the effect build this repo pins ([email protected]) and ran it:

    effect 4.0.0-beta.83 | node v24.21.0
    
    == A. as shipped: Effect.cached(...), download fails once ==
        [log] level=INFO message="downloading ripgrep" url=https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-pc-windows-msvc.zip
      call #1: FAIL -> Error: fetch failed   (resolver body ran 1x, disk checked 1x)
      -- operator drops a known-good rg into the cache dir (report step 6) --
      call #2: FAIL -> Error: fetch failed   (resolver body ran 1x, disk checked 1x)
      call #3: FAIL -> Error: fetch failed   (resolver body ran 1x, disk checked 1x)
    
    == B. control: identical body, no Effect.cached ==
        [log] level=INFO message="downloading ripgrep" url=https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-pc-windows-msvc.zip
      call #1: FAIL -> Error: fetch failed   (resolver body ran 1x, disk checked 1x)
      -- operator drops a known-good rg into the cache dir (report step 6) --
      call #2: ok -> /tmp/rg-cache-hzSR8w/rg   (resolver body ran 2x, disk checked 2x)
      call #3: ok -> /tmp/rg-cache-hzSR8w/rg   (resolver body ran 3x, disk checked 3x)
    
    == C. as shipped: user hits Esc while the first download is in flight ==
        [log] level=INFO message="downloading ripgrep" url=https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-pc-windows-msvc.zip
      call #1: FAIL -> Error: aborted   (resolver body ran 1x, disk checked 1x)
      -- operator drops a known-good rg into the cache dir (report step 6) --
      call #2: FAIL -> Error: All fibers interrupted without error   (resolver body ran 1x, disk checked 1x)
      call #3: FAIL -> Error: All fibers interrupted without error   (resolver body ran 1x, disk checked 1x)
    
    == D. ripgrep.ts:147 mapError applied to that same failure ==
      (binary error) instanceof (module-local Error, ripgrep.ts:41) = false
      message handed to the model : "ripgrep execution failed"
      real cause, never logged    : "Error: fetch failed"
    

    A is your steps 4-7 exactly: one failed download, then calls 2 and 3 still fail with the cached error, resolver body ran 1x, disk checked 1x, so the rg you placed in the cache dir is never looked at. B is the identical body without Effect.cached and recovers on the very next call, no restart.

    Two things the report does not cover yet.

    C - you do not need a proxy to hit this. ripgrep.ts:144 races the program against waitForAbort(input.signal). If the first grep is cancelled while the download is still in flight, the interrupt is what gets memoized, and every later call in that process fails with All fibers interrupted without error, which then maps to ripgrep execution failed. That reproduces on Linux/macOS with a perfectly good network. Related: binary.ts:110-114 has no timeout on http.execute, so a proxy that blackholes the connection parks the cached deferred instead of failing it, and every later caller waits on that same deferred.

    D - why the cause never reaches the log or the model. ripgrep.ts:41 is export class Error extends Schema.TaggedErrorClass(...), which shadows the global Error for the rest of that module. The download failure built at binary.ts:113 is a plain Error, so cause instanceof Error at line 147 is false and it falls into failure("ripgrep execution failed", cause). The cause is attached to the tagged error but nothing ever logs it, which is exactly the "nothing in the log after the INFO line" you saw.

    Minimal standalone repro for C, no Windows and no proxy needed:

    import { Cause, Effect } from "effect"
    const body = Effect.gen(function* () {
      console.log("resolver body ran")
      return yield* Effect.never                        // download blackholed by the proxy
    })
    const filepath = await Effect.runPromise(Effect.cached(body))
    const abort = (ms) => Effect.callback((resume) => { setTimeout(() => resume(Effect.fail(new Error("aborted"))), ms) })
    const show = async (n, eff) => {
      const exit = await Effect.runPromiseExit(eff)
      console.log(`call #${n}:`, exit._tag === "Success" ? exit.value : String(Cause.squash(exit.cause)))
    }
    await show(1, filepath.pipe(Effect.raceFirst(abort(300))))   // ripgrep.ts:144 abort race
    await show(2, filepath)                                      // body never runs again
    await show(3, filepath)
    $ node snippet.mjs
    resolver body ran
    call #1: Error: aborted
    call #2: Error: All fibers interrupted without error
    call #3: Error: All fibers interrupted without error
    

    The resolver body runs once and never again; call #1's interrupt is what the cache latches.

    On the proxy half specifically, there is no proxy handling anywhere in the tree, so the download client is whatever the runtime's global fetch does and nothing more:

    $ git log -1 --format='%h %ad %s' --date=short   # current main
    5fcfb06 2026-09-24 chore: update nix node_modules hashes
    
    $ grep -rniE 'proxyAgent|HTTPS?_PROXY|NODE_USE_ENV_PROXY|setGlobalDispatcher' --include='*.ts' packages/ | wc -l
    0
    
    $ sed -n '10p' packages/core/src/effect/app-node-platform.ts
    export const httpClient = makeGlobalNode({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
    
    $ sed -n '92p;108,115p' packages/core/src/ripgrep/binary.ts
            filepath: yield* Effect.cached(
                yield* Effect.logInfo("downloading ripgrep", { url })
                yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
                const bytes = yield* HttpClientRequest.get(url).pipe(
                  http.execute,
                  Effect.flatMap((response) => response.arrayBuffer),
                  Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
                )
                if (bytes.byteLength === 0) throw new Error(`failed to download ripgrep from ${url}`)
    

    Any fix there has to install a dispatcher/agent explicitly; reading HTTP_PROXY/HTTPS_PROXY in application code will not change FetchHttpClient on its own.

    Worth knowing before anyone writes a patch: the resolver half is already filed as Bug 2 of the still-open #40623, and a fix for it ("single-flight memo that caches only successful resolutions") has been submitted twice and landed neither time. #41015 was auto-closed by the PR-standards bot for a missing linked issue, and #41028 was closed by its author for lack of review bandwidth, with the branch kept at Yuxin-Qiao/opencode-fix fix/ripgrep-windows-resolution. Current main (5fcfb06) still has Effect.cached at binary.ts:92. So the genuinely new content in this issue is the proxy blindness, the missing fetch timeout, and the swallowed cause; the "never recovers" part has a ready patch that just needs a review.

    Same shape bit us in our own scheduled agents: a failure that announces itself once and then latches, after which "broken" and "fine" emit identical output. The catalog entry we keep for it is in our repo (ours): https://github.com/aron-intframe/agent-guards#3-the-longer-something-rots-the-quieter-it-gets

  2. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I could reproduce this, and it isn't limited to Windows or proxies: any failed ripgrep download is enough.

    What I did on v2.0.12 (Linux): took rg off PATH, made github.com unreachable, then had the model call grep.

    1. First call: the download fails and the tool returns only ripgrep execution failed.
    2. Valid binary placed by hand: I copied a working rg into ~/.cache/opencode/bin/rg, the folder the resolver checks.
    3. Second call, same server: it still returns ripgrep execution failed. No new download attempt is made and the binary on disk is never used.

    It behaves the same on the latest dev (fa1260d). As you suspected, the resolver in packages/core/src/ripgrep/binary.ts is wrapped in Effect.cached, which keeps the first failure until restart. The recording and script below show the reproduction.

    I couldn't test the Windows system-proxy part here. Until this is addressed, your workaround of putting rg.exe on PATH or in the cache folder before starting OpenCode is the reliable option.

    recording.mp4

    Screenshot

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#51022: a failed ripgrep download is cached for the
    // server's lifetime; a valid rg placed in the cache dir afterwards is never picked up.
    //
    // Prerequisites (run as root before this script):
    //   mkdir -p /opt/rgsave && mv "$(command -v rg)" /opt/rgsave/rg   # rg not on PATH, keep a copy
    //   echo "127.0.0.1 github.com" >> /etc/hosts      # ripgrep download fails
    //   (RG_SOURCE=/opt/rgsave/rg is copied into the cache bin dir mid-run)
    import { Effect, Stream } from "effect"
    import { execSync } from "node:child_process"
    import fs from "node:fs"
    import path from "node:path"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const RG_SOURCE = process.env.RG_SOURCE ?? "/opt/rgsave/rg"
    
    const grep = (id: string) =>
      [Llm.toolCall({ index: 0, id, name: "grep", input: { pattern: "value" } }), Llm.finish("tool-calls")] as const
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        project: { git: true, files: { "src/value.ts": "export const value = 1\n" } },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          // The simulated model echoes the grep tool result it received, so it is visible on screen
          const resultOf = (request: unknown) => {
            const text = JSON.stringify(request)
            const hits = [...text.matchAll(/ripgrep execution failed|src\/value\.ts|Found \d+ match|No files found/g)].map((m) => m[0])
            return hits.at(-1) ?? "(no grep result found)"
          }
          // Answer each user turn with one grep call, then echo the tool result it got back.
          let turn = 0
          yield* llm.serve((request) => {
            const messages = (request as any)?.body?.messages ?? []
            const last = messages.at(-1)
            if (last?.role === "user") {
              turn++
              return Stream.make(...grep(`call_grep_${turn}`))
            }
            const lastUser = [...messages].reverse().find((m: any) => m.role === "user")
            const n = JSON.stringify(lastUser ?? "").includes("again") ? 2 : 1
            return Stream.make(Llm.text(`Grep #${n} result: ${resultOf(request)}. Done.`))
          })
          // 1st grep: rg not on PATH, download from github.com fails
          yield* ui.submit("Search for value (download will fail)")
          yield* ui.waitFor("Grep #1 result", { timeout: 60_000 })
          yield* ui.screenshot("after-first-grep")
    
          // Put a valid rg where the resolver looks (<cache>/opencode/bin/rg)
          const placed = yield* Effect.sync(() => {
            const dirs = execSync(
              `find / -xdev -type d -path '*opencode/bin' -newer /etc/hosts 2>/dev/null || true`,
            )
              .toString()
              .split("\n")
              .filter((d) => d && !d.includes("node_modules") && !d.startsWith("/proc"))
            for (const d of dirs) {
              fs.copyFileSync(RG_SOURCE, path.join(d, "rg"))
              fs.chmodSync(path.join(d, "rg"), 0o755)
            }
            return dirs
          })
          yield* Effect.log(`placed rg in: ${placed.join(", ")}`)
    
          // 2nd grep, same server process: should now find src/value.ts
          yield* ui.submit("Search for value again (rg is now in the cache dir)")
          yield* ui.waitFor("Grep #2 result", { timeout: 60_000 })
          yield* Effect.sleep(1000)
          yield* ui.screenshot("after-second-grep")
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  3. Anthony14FR commented on Nov 12, 2026

    @Anthony14FR

    I'd like to take this. On current dev the resolver in packages/core/src/ripgrep/binary.ts is still wrapped in Effect.cached, so one failed download is memoized until restart. My plan is to cache only a successful resolution, so the next search retries after a failure, plus a regression test where the first attempt fails and the second succeeds. #51192 did this before and was closed by the stale-PR cleanup (no linked issue), not on review, so I'll link this issue this time. The Windows system-proxy part would be a separate change.

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