← reports

#2362 · File listing crashes with 502 on large workspaces

Bug Priority: High Effort: not set workspaces host open on GitHub 2026-08-27 · base ad79bbb5ec90

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

A recursive file list fails when one child returns more paths than V8 accepts as function arguments.

A real child with 130,000 files caused the reported RangeError at the base commit.

The server changes this host failure into an HTTP 502 response.

The error is not fully opaque because the API and SDK keep the stack-limit message.

2. Claims vs findings

Claim from the issueStatusEvidence
“Any host command that recursively lists paths … crashes on a workspace with a large enough file tree.” Verified, with scope The 130,000-file child caused the host command to fail at file-list.ts:159. The command router caught the error, so the daemon process stayed active. See the base test log.
“Roughly 100k+ files somewhere in its tree” reproduce the failure. Qualified One child result must cross the engine limit. Node 24 accepted 125,212 arguments and rejected 125,213. A flat root does not use this spread merge. See the limit log.
results.push(...childResults) crosses V8's argument-count ceiling and throws the reported RangeError. Verified The file test and the independent argument-limit script failed at the spread call.
The failure “shows up as an opaque HTTP 502 … with no useful error surfaced.” Refuted A server mapping test injected a synthetic failed host response. It returned HTTP 502 with command_failed and the stack-limit message. The code trace connects the real RangeError to that response.
The RangeError “propagates up as an unhandled error.” Refuted The command router catches the error and returns a failed host response. The host command fails, but the daemon process does not exit.

3. Environment

The shared /tmp file system had a low inode count. The controlled tests used a different temporary directory.

4. Minimal reproduction

  1. Check out the report base and install the repository.
    git checkout ad79bbb5ec909524f8f281e62d860c588a86f332
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  2. Save the test into the host-daemon package. Use the first command after publication. Use the second command in the report workflow.
    # Published report
    curl -fsSLo apps/host-daemon/src/command-handlers/file-list.issue-2362.test.ts \
      https://get-bb.github.io/reports/issues/2362/repro/issue-2362-file-list.test.ts
    
    # Local verifier before publication
    cp /tmp/bb-reports/issues/2362/repro/issue-2362-file-list.test.ts \
      apps/host-daemon/src/command-handlers/file-list.issue-2362.test.ts
  3. Check that /var/tmp has at least 130,005 free inodes. Create an isolated temporary directory on that file system.
    df -i /var/tmp
    issue_tmpdir=$(mktemp -d /var/tmp/bb-2362-XXXXXX)
  4. Run the test from the package directory. The test removes its 130,000 files. Remove the empty temporary directory afterwards.
    cd apps/host-daemon
    TMPDIR="$issue_tmpdir" pnpm exec vitest run \
      src/command-handlers/file-list.issue-2362.test.ts \
      --config vitest.config.ts
    repro_status=$?
    rmdir "$issue_tmpdir"
    test "$repro_status" -eq 1

Expected:

Test Files  1 passed (1)
Tests       1 passed (1)

Actual:

FAIL  |@bb/host-daemon| src/command-handlers/file-list.issue-2362.test.ts
RangeError: Maximum call stack size exceeded
 ❯ listPathsRecursively src/command-handlers/file-list.ts:159:15

Test Files  1 failed (1)
Tests       1 failed (1)

Reproduction test source

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listPathsRecursively } from "./file-list.js";

describe("issue 2362", () => {
  it("lists a child directory with 130000 files", async () => {
    const root = await fs.mkdtemp(path.join(os.tmpdir(), "bb-2362-"));
    try {
      const child = path.join(root, "child");
      await fs.mkdir(child);
      const fileCount = 130_000;
      const batchSize = 500;
      for (let start = 0; start < fileCount; start += batchSize) {
        const end = Math.min(start + batchSize, fileCount);
        await Promise.all(
          Array.from({ length: end - start }, (_, offset) =>
            fs.writeFile(path.join(child, `f${start + offset}.txt`), ""),
          ),
        );
      }

      const result = await listPathsRecursively({
        dir: root,
        root,
        includeFiles: true,
        includeDirectories: false,
      });

      expect(result).toHaveLength(fileCount);
    } finally {
      await fs.rm(root, { recursive: true, force: true });
    }
  }, 120_000);
});

Files: tree test, base log, and limit script.

HTTP result check

This server mapping test injects a synthetic command_failed host response with the stack-limit message.

It does not run the 130,000-file daemon path. The code trace below connects the real RangeError to the failed host response.

HTTP 502
{
  "code": "command_failed",
  "message": "Maximum call stack size exceeded",
  "retryable": false
}

Files: HTTP test and HTTP test log.

5. Root cause

Both list commands collect the full recursive result before they apply the output limit.

See host-files.ts lines 66–121.

The directory branch spreads each child array into Array.push.

results.push(
  ...(await listPathsRecursively({
    ...args,
    dir: fullPath,
  })),
);

See file-list.ts lines 137–176.

JavaScript turns each spread element into a function argument. V8 rejects the call when the argument count becomes too large.

The exact limit depends on the engine and call depth. This Node process rejected 125,213 elements.

The command router catches the RangeError and returns the message with command_failed.

See command-router.ts lines 112–155.

The server maps every failed online host response to HTTP 502.

See online-rpc.ts lines 128–130.

The SDK keeps the response message inside BbHttpError.

See response.ts lines 97–108.

The deeper cost remains: the traversal builds all paths before it applies a limit. It also copies descendant arrays at each directory level.

6. Proposed fix (first principles)

Merge PR #2363 because its loop removes the argument-count limit without a wire change.

A protocol version change is not necessary because the command and response shapes do not change.

A later change should pass one result accumulator through the recursion. That design avoids child arrays and repeated copies.

Keep the current path order, hidden-path rules, symlink rules, and final limit behavior.

7. PR review

PR #2363 · avoid stack overflow for large directory trees

The code replaces spread-push with a loop over each child result.

See the fix at lines 151–164.

This change fixes the root cause and preserves result order.

SeverityFindingResult
None The code change has no protocol, contract, cast, security, or behavior defect. No blocker
Low The regression test creates 150,000 real files. A busy shared temporary volume can run out of inodes. Keep the test or replace it with a deterministic file-system seam.

See the PR test at lines 266–293.

Tests

Logs: PR head, merged focus, typecheck, and full suite.

An earlier shared-temp run was invalid. My duplicate stress test and the PR test exhausted the shared inode supply.

The invalid run log records that environment failure.

Verdict: MERGE.

8. Related issues

9. Appendix

Important commands

pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
gh issue view 2362 --comments
gh pr view 2363 --comments
gh pr diff 2363

issue_tmpdir=$(mktemp -d /var/tmp/bb-2362-XXXXXX)
TMPDIR="$issue_tmpdir" pnpm exec vitest run \
  src/command-handlers/file-list.issue-2362.test.ts \
  --config vitest.config.ts
rmdir "$issue_tmpdir"

pnpm exec vitest run test/files/issue-2362-http.test.ts \
  --config vitest.config.ts

suite_tmpdir=$(mktemp -d /var/tmp/bb-2362-suite-XXXXXX)
TMPDIR="$suite_tmpdir" pnpm exec turbo run test \
  --filter=@bb/host-daemon --force --env-mode=loose
rmdir "$suite_tmpdir"

pnpm exec turbo run typecheck --filter=@bb/host-daemon

Artifacts

Verification

The independent verifier first used shared /tmp. The test failed with ENOSPC before it reached the target code.

This revision adds the exact /var/tmp setup and cleanup commands. That file system had 50,631,196 free inodes.

I repeated those commands at the base commit. The test created 130,000 files and reproduced the same RangeError at line 159.

I also repeated the synthetic server mapping test. It passed and confirmed the HTTP 502 body.

The verifier used the local artifact copy because the prepublication URL returned HTTP 404. The publication check must confirm HTTP 200.

curl -fsS -o /dev/null -w '%{http_code}\n' \
  https://get-bb.github.io/reports/issues/2362/repro/issue-2362-file-list.test.ts