Skip to content

core: portable shell scanner takes quadratic time in the argument count #59165

Open

Description

@LeizhenZhang

Description

ShellScan.scan in packages/core/src/shell/scan.ts classifies every word of a command. For each word it walks the whole precommand chain again (bashBuiltinIndex). For printf, set and declare/export it also copies every earlier argument (state.words.slice(name + 1)). One command with many arguments therefore takes quadratic time, and the scan blocks the server while it runs. The portable scanner is used on the local and dev channels and with experimental.portable_shell_scanner.

Expected

Time that grows linearly with the argument count, as it does for echo.

Actual

command 2000 args 8000 args 32000 args
printf a a ... 72 ms 441 ms 7735 ms
set a a ... 46 ms 525 ms 6250 ms
declare -a -a ... 113 ms 1491 ms
command command ... ls 100 ms 1202 ms
echo a a ... (control) 2 ms 8 ms 29 ms

The existing "bounded time" cases in packages/core/test/shell-scan/adversarial.test.ts use a 500 ms budget.

Plugins

None

OpenCode version

dev be315b1 (2.1.4), Linux x64, Bun 1.4.4

Steps to reproduce

import { ShellScan } from "./packages/core/src/shell/scan.ts"
ShellScan.scan("printf " + "a ".repeat(32_000), "bash")

Screenshot

No response

Operating System

Ubuntu (Linux x64)

Terminal

No response

Activity

  1. opencode-agent commented on Oct 26, 2026

    @opencode-agent
    Contributor

    Thanks for the clear report and numbers. I could reproduce this by calling ShellScan.scan(..., "bash") directly on v2.1.4, and it still happens on the latest dev (c54677bece).

    When the argument count doubles, the scan takes about four times as long. Timings at 2000 / 8000 / 16000 args on v2.1.4:

    • printf a a ...: 65 / 665 / 2517 ms
    • set a a ...: 27 / 309 / 1663 ms
    • declare -a -a ...: 53 / 819 / 3329 ms
    • command command ... ls: 37 / 523 ms
    • echo a a ... (control): 1 / 6 / 8 ms

    Latest dev gives nearly the same numbers (e.g. printf 2504 ms and declare 3163 ms at 16000 args). That is well past the 500 ms budget the existing bounded-time tests use. The cause matches your description: for every word, bashWordEvaluation re-walks the precommand chain in bashBuiltinIndex and copies all earlier words with state.words.slice(name + 1).

    The script below reproduces it; pass it the path to an OpenCode checkout. Your PR #59163 addresses this and is still open.

    Reproduction script (scan-timing.ts)
    // Usage: bun /workspace/repro/scan-timing.ts <path-to-opencode-checkout>
    const repo = process.argv[2] ?? "/workspace/repo"
    const { ShellScan } = await import(`${repo}/packages/core/src/shell/scan.ts`)
    const cases: Record<string, (n: number) => string> = {
      "printf a a ...": (n) => "printf " + "a ".repeat(n),
      "set a a ...": (n) => "set " + "a ".repeat(n),
      "declare -a -a ...": (n) => "declare " + "-a ".repeat(n),
      "command command ... ls": (n) => "command ".repeat(n) + "ls",
      "echo a a ... (control)": (n) => "echo " + "a ".repeat(n),
    }
    for (const [name, build] of Object.entries(cases)) {
      const row: string[] = []
      for (const n of [2000, 8000, 16000]) {
        const input = build(n)
        const t = performance.now()
        ShellScan.scan(input, "bash")
        row.push(`${n}: ${(performance.now() - t).toFixed(0)} ms`)
      }
      console.log(name.padEnd(26), row.join("  "))
    }

    Run it: bun scan-timing.ts

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