Skip to content

fs: /cd and file pickers fail on FreeBSD/ZFS — stat throws "dev exceeds the safe integer range" (fixed in @effect/platform-node-shared 4.0.5) #57518

Open

Description

@asomers

Description

Summary

OpenCode's Node filesystem layer comes from @effect/platform-node-shared, whose stat calls fs.stat(path, { bigint: true }) and then converts the BigInt dev field to a Number, throwing a RangeError when the value is outside the safe-integer range. FreeBSD reports 64-bit st_dev values for ZFS datasets (e.g. 8599779474788709465), so stat fails for essentially every path on the host. The visible result is that GET /api/fs/list, /api/fs/find, and /api/fs/read return HTTP 500, POST /api/session/{id}/move answers "Directory does not exist" for directories that do exist, and the TUI /cd command is unusable.

Effect already fixed this upstream in @effect/platform-node-shared 4.0.5 (Effect-TS/effect#9112), which keeps dev as a BigInt. OpenCode still pins 4.0.1 in its workspace catalog, in v2.0.32, in v2.1.1, and on dev.

Environment

  • opencode version: 2.0.32 (channel: latest)
  • OS: FreeBSD 15.1-RELEASE (freebsd x64), ZFS 2.4.2-1-FreeBSD_g6330a45b0 (root dataset zroot/ROOT/15.1-RELEASE/usr/homeonzroot/usr/home`)
  • Bun (embedded in the opencode binary): v1.4.2 (744846f84) FreeBSD x64
  • Terminal: TERM=xterm-256color, TERM_PROGRAM=tmux, COLORTERM=truecolor
  • Shell: /usr/local/bin/bash
  • Install/channel: release (latest)
  • Active plugins: none found (opencode plugin list → "No plugins found"); no opencode.json/opencode.jsonc/cli.json present
  • Pinned dependency: "@effect/platform-node": "4.0.1", "@effect/platform-node-shared": "4.0.1", "effect": "4.0.1" (workspace catalog in package.json)

Reproduction

  1. Run OpenCode on a FreeBSD host whose working directory is on a ZFS dataset.
  2. In the TUI, type /cd and keep typing a path.
  3. Observe: Could not search directories. Keep typing to try again. The dropdown stays empty, and Enter is swallowed by the autocomplete handler, so the command cannot be submitted.
  4. The same failure occurs server-side when the move is issued directly:
$ opencode api post /api/session/<id>/move --data '{"directory":"/usr/home/asomers/src/github"}'
{"_tag":"InvalidRequestError","message":"Directory does not exist: /usr/home/asomers/src/github"}
  1. The directory listing endpoints fail with HTTP 500:
$ opencode api get "/api/fs/list?path=/usr/home/asomers&location.directory=/usr/home/asomers"
HTTP 500 Internal Server Error

$ opencode api get "/api/fs/find?query=src&limit=5&location.directory=/usr/home/asomers"
HTTP 500 Internal Server Error

$ opencode api get "/api/fs/read//usr/home/asomers/.profile"
HTTP 500 Internal Server Error

/usr/home/asomers/src/github exists (drwxr-xr-x 28 asomers asomers 29 Aug 7 14:40), so the "Directory does not exist" response is a false negative.

Expected Behavior

  • GET /api/fs/list returns the children of the requested directory.
  • POST /api/session/{id}/move moves the session to the requested directory.
  • In the TUI, /cd <path> lists matching directories and changes the session's working directory.

Actual Behavior

The filesystem layer throws before producing a result:

PlatformError: FileSystem.stat: dev exceeds the safe integer range: 8599779474788709465
    at Ec (/$bunfs/root/chunk-6xc3bwx4.js:2:980)
    at <anonymous> (/$bunfs/root/chunk-79zk6hn4.js:18:6249)
    at FileSystem.list (/$bunfs/root/chunk-26fat8ct.js:449:16850)
    at ServerProcess.start (/$bunfs/root/chunk-sz35r0mr.js:2:7534)

Root Cause

The throwing code is not in the OpenCode repository and not in the Bun runtime. It comes from @effect/platform-node-shared, in packages/platform/node-shared/src/NodeFileSystem.ts of https://github.com/Effect-TS/effect (tag effect@4.0.1):

// packages/platform/node-shared/src/NodeFileSystem.ts:37
const bigintToNumber = (value: bigint, field: string): number => {
  const number = Number(value)
  if (!Number.isSafeInteger(number)) {
    throw new RangeError(`${field} exceeds the safe integer range: ${value}`)
  }
  return number
}
// packages/platform/node-shared/src/NodeFileSystem.ts:533
      dev: bigintToNumber(stat.dev, "dev"),
      rdev: bigintToNumberOption(stat.rdev),
      ino: bigintToNumberOption(stat.ino),
      mode: bigintToNumber(stat.mode, "mode"),

stat requests { bigint: true } and maps the fields back. dev and mode go through the strict bigintToNumber, which throws; the other fields use bigintToNumberOption, which returns undefined instead of throwing. dev is the field that breaks on FreeBSD/ZFS.

Relevant st_dev values on the affected host:

Path Filesystem st_dev
/usr/home zroot/usr/home 8599779474788709465 (throws)
/usr/local zroot/usr/local 17163752400735175193 (throws)
/tmp zroot/var/tmp 10799770302823152918 (throws)
/dev devfs 1895890688 (works)

/dev succeeds, which is consistent with the diagnosis: only filesystems whose device ID exceeds 2^53−1 are affected.

Already fixed upstream in Effect

Effect PR Effect-TS/effect#9112 changed the mapping in @effect/platform-node-shared 4.0.5:

      dev: toUint64(stat.dev),
      rdev: toUint64Option(stat.rdev),

Verified against the standalone packages (effect@4.0.5, @effect/platform-node@4.0.5) on the same host:

import { NodeFileSystem } from "@effect/platform-node"
import { Effect } from "effect"
import { FileSystem } from "effect/FileSystem"

const program = Effect.gen(function* () {
  const fs = yield* FileSystem
  console.log(yield* fs.stat("/usr/home/asomers"))
})

Effect.runPromise(program.pipe(Effect.provide(NodeFileSystem.layer)))
stat OK: { type: "Directory", ..., dev: 8599779474788709465n, ... }

stat succeeds and dev is returned as an exact BigInt. The same script against @effect/platform-node-shared@4.0.1 throws RangeError: dev exceeds the safe integer range: 8599779474788709465.

Versions are affected as follows:

@effect/platform-node-shared dev mapping
4.0.0 – 4.0.4 bigintToNumber(stat.dev, "dev") (throws)
4.0.5 toUint64(stat.dev) (fixed)

Additional Context

  • Impact surface on the affected host:
    • TUI /cd directory autocomplete: broken ("Could not search directories. Keep typing to try again.")
    • TUI @ file autocomplete: broken ("Could not search files. Keep typing to try again.")
    • POST /api/session/{id}/move: broken with a misleading "Directory does not exist" error
    • GET /api/fs/read on an existing file: HTTP 500
    • Session creation in a ZFS directory still works (POST /api/session with location.directory), so opencode <dir> is unaffected
    • The agent's read/grep/glob/shell tools still work; they do not use the affected endpoints
  • Suggested change: bump the workspace catalog to @effect/platform-node / @effect/platform-node-shared / effect 4.0.5 or later, which picks up the upstream Effect fix. A dependency bump is sufficient; no OpenCode code change is needed.
  • Reproduces consistently on every request; it is not intermittent.
  • Workaround used on this host: start OpenCode in the target directory (opencode ~/src) or create a session there via the API (POST /api/session with location.directory) instead of using /cd.

Plugins

None

OpenCode version

v2.0.32

Steps to reproduce

No response

Screenshot

No response

Operating System

FreeBSD 15.1 amd64

Terminal

Wezterm

Activity

  1. opencode-agent commented on Oct 20, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report and for tracking down the upstream cause. I can reproduce this.

    Our sandbox runs Linux, which can't produce a device ID that large, so I used a preload to make every bigint stat return your /usr/home value (st_dev = 8599779474788709465). Then I ran opencode serve from source:

    • Without the preload, GET /api/fs/list and GET /api/fs/read/... return HTTP 200.
    • With it, both return HTTP 500, and the server logs FileSystem.stat: dev exceeds the safe integer range: 8599779474788709465 from FileSystem.list / FileSystem.read. This matches your trace.

    I got the same result at v2.0.32 and on the latest dev (a13c48b). Both still pin effect / @effect/platform-node(-shared) at 4.0.1. I didn't test the TUI /cd flow itself, since the preload can't go into the release binary, but it calls the same endpoints. The script below reproduces it. Thanks also for posting the workaround of starting OpenCode in the target directory.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces #57518 on Linux by simulating FreeBSD/ZFS's 64-bit st_dev.
    # Usage: REPO=/path/to/opencode-checkout ./repro.sh   (needs `bun install` done in REPO)
    set -u
    REPO=${REPO:-/workspace/repo}
    W=$(mktemp -d); HERE=$W
    cat > "$W/zfs-dev-shim.ts" <<'SHIM'
    // Preload that simulates FreeBSD/ZFS on Linux: every bigint stat/lstat/fstat
    // reports st_dev = 8599779474788709465 (the /usr/home value from the report;
    // Linux dev_t can never exceed 2^32, so it has to be injected).
    // Bun's `import * as NFS from "node:fs"` namespace can't be monkey-patched,
    // so a Bun plugin rewrites @effect/platform-node-shared's NodeFileSystem.js
    // to call the wrapped functions instead of the real ones.
    import fs from "node:fs"
    import { plugin } from "bun"
    
    const DEV = 8599779474788709465n
    const wrap = (orig: any) =>
      function (p: any, opts: any, cb: any) {
        if (typeof opts === "function") return orig.call(fs, p, opts)
        return orig.call(fs, p, opts, (err: any, st: any) => {
          if (st && typeof st.dev === "bigint")
            st = new Proxy(st, {
              get: (t, k) => (k === "dev" ? DEV : typeof t[k] === "function" ? t[k].bind(t) : t[k]),
            })
          cb(err, st)
        })
      }
    ;(globalThis as any).__zfs = { stat: wrap(fs.stat), lstat: wrap(fs.lstat), fstat: wrap(fs.fstat) }
    
    plugin({
      name: "zfs-dev-shim",
      setup(build) {
        build.onLoad({ filter: /platform-node-shared\/dist\/NodeFileSystem\.js$/ }, async (args) => {
          let src = await Bun.file(args.path).text()
          src = src.replace(/effectify\(NFS\.(stat|lstat|fstat),/g, "effectify(globalThis.__zfs.$1,")
          return { contents: src, loader: "js" }
        })
      },
    })
    SHIM
    mkdir -p "$W/proj" "$W/home"; echo hi > "$W/proj/a.txt"
    export HOME=$W/home XDG_DATA_HOME=$W/home/d XDG_CONFIG_HOME=$W/home/c XDG_STATE_HOME=$W/home/s XDG_CACHE_HOME=$W/home/k
    run() { # $1 = port, rest = extra bun flags
      local port=$1; shift
      (cd "$W/proj" && OPENCODE_PASSWORD=pw bun --conditions=browser "$@" \
        --preload="$REPO/packages/tui/node_modules/@opentui/solid/scripts/preload.js" \
        "$REPO/packages/cli/src/index.ts" serve --port "$port" --print-logs >"$W/serve-$port.log" 2>&1 &
       echo $! >"$W/pid-$port")
      for _ in $(seq 60); do curl -s "http://127.0.0.1:$port" >/dev/null && break; sleep 0.5; done
      for u in "/api/fs/list?path=$W/proj&location.directory=$W/proj" "/api/fs/read/$W/proj/a.txt?location.directory=$W/proj"; do
        echo "GET $u"; curl -s -u opencode:pw -w "\n-> HTTP %{http_code}\n" "http://127.0.0.1:$port$u" | head -c 300; echo
      done
      pkill -P "$(cat "$W/pid-$port")" 2>/dev/null; kill "$(cat "$W/pid-$port")" 2>/dev/null
      grep -o "dev exceeds the safe integer range[^\"]*" "$W/serve-$port.log" | sort -u | head -3
    }
    echo "== normal Linux st_dev (control)"; run 4191
    echo "== simulated FreeBSD/ZFS st_dev"; run 4192 --preload="$HERE/zfs-dev-shim.ts"

    Run it: bash repro.sh

  2. argszero commented on Oct 21, 2026

    @argszero
    Contributor

    I'd like to work on this.

    I confirmed the fix is present in the published artifact, not just in the linked Effect PR: in the @effect/platform-node-shared@4.0.5 tarball, makeFileInfo now reads dev: toUint64(stat.dev) and rdev: toUint64Option(stat.rdev), where 4.0.1 had dev: bigintToNumber(stat.dev, "dev"). The RangeError from the 64-bit ZFS st_dev therefore cannot fire any more. The remaining bigintToNumber calls in that file are on mode and file position, both narrow enough to stay within the safe-integer range.

    The plan is the coordinated bump rather than a single-package one: move effect, @effect/opentelemetry, @effect/platform-node, @effect/platform-node-shared and @effect/sql-sqlite-bun in the workspace catalog from 4.0.1 to 4.0.5 together, then resolve anything the FileInfo.dev type change surfaces (it goes from number to a 64-bit value, so any consumer that assumed a number needs a look), and run the filesystem, tool and permission suites.

    If you would rather carry this bump in a release-wide dependency update of your own instead of a PR from a contributor, say so and I will drop it.

  3. argszero commented on Oct 21, 2026

    @argszero
    Contributor

    I implemented the bump I proposed above, and it needs one decision from you before it can be a PR. Summary first: the ZFS fix is real and the bump is the right shape, but effect 4.0.5 also changes a client type in a way that breaks the generator's own fixture, and fixing that properly is a second change I do not want to smuggle into a dependency bump.

    The bump has to move the whole family, not one package. @effect/platform-node-shared@4.0.5 declares peerDependencies: { "effect": "^4.0.5" } (read from the published tarball's package.json), so it cannot be paired with effect 4.0.1. All five entries move together: effect, @effect/opentelemetry, @effect/platform-node, @effect/platform-node-shared, @effect/sql-sqlite-bun, 4.0.1 → 4.0.5. That is five lines in the root catalog plus bun.lock.

    Baseline, so the failures below are attributable. In a fresh clone at dev (15adc3d91e), bun turbo typecheck --continue: @opencode/posts and @opencode/www already fail, on Cannot find module 'astro:content' — astro's virtual modules are not synced in a clean checkout, so those are environmental and unrelated. Everything else, including @opencode/httpapi-codegen, passes.

    At 4.0.5, exactly one additional package fails: @opencode/httpapi-codegen, and it raised two errors. The first is already fixed on my branch; the second is what needs your call.

    1. test/write.test.ts:140 builds a FileSystem.Info fixture with dev: 0, and FileInfo.dev is now a 64-bit value, so it must be dev: 0n. Trivial, and a direct consequence of the fix this issue is about — it belongs in the bump, and I have made that change.

    2. test/generated-consumer.ts:27 — the _configure assertion, the one remaining error at 4.0.5. Only session.configure, the one endpoint called with a payload, widens; every other endpoint in that fixture keeps its precise type. It now infers Effect<string | HttpClientResponse | [string, HttpClientResponse], ClientError> instead of Effect<string, ClientError>.

      The cause is in HttpApiClient's Method type. 4.0.1 has

      <Mode extends ResponseMode = ResponseMode>(request: ...) => MethodReturn<Endpoint, E, R, Mode>
      type Response<Success, Mode> = [Mode] extends ["decoded-and-response"] ? ... : [Mode] extends ["response-only"] ? ... : Success

      and 4.0.5 has <Mode extends ResponseMode = "decoded-only"> with the brackets dropped in Response, so the conditional became distributive. When the mode is inferred as the whole union — which is what happens for a request carrying a payload in the generator's plain-call emission — all three branches survive and the return type is the union.

      The client users actually import is not affected: codegen wraps those calls in preserveEffect<Output>(), which pins the contract, and @opencode/client typechecks clean at 4.0.5. The widening only shows in the fixture, which emits the unwrapped call, and that fixture exists precisely to catch a widened generated output — so it is doing its job.

    What I have not done, and why I am asking. The honest fix is in the generator: emit the response mode explicitly at the raw call site (packages/httpapi-codegen/src/index.ts:687, and the same emission at :1823), so generated wrappers keep a precise type instead of depending on an inference default that upstream just changed. That regenerates the committed generated client, so it is a real change with a large mechanical diff, not a one-line adaptation — and I did not want to fold it into a dependency bump without your word.

    How would you like to proceed?

    1. One PR from me: bump the family to 4.0.5, fix the dev fixture, and pin the response mode in the codegen emitter with the regenerated output.
    2. One PR from me: the bump plus the dev fixture only, and you take the codegen fixture however you prefer.
    3. You would rather carry the family bump yourself, or wait for an upstream Effect fix to the Response conditional.

    I have not opened a PR for this yet, because the tree is red at 4.0.5 and I will not submit a red one. The branch and the measured diff are sitting locally and are ready to go the moment you pick a direction.

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