Skip to content

rw_is_ripwire_call is cubic in command length and holds a PreToolUse Bash call for minutes #327

Description

@KilimcininKorOglu

Summary

rw_is_ripwire_call in hooks/ripwire-nudge.sh takes time cubic in the length of the command line. The PreToolUse hook runs it before every Bash tool call, so a long command (for example one that carries a heredoc script) holds the tool call for minutes. In one Claude Code session a single Bash call stayed at running PreToolUse hooks… 3/4 while ripwire-nudge.sh ran at 99% CPU for 6 min 49 s, with no child process and stdin already closed.

The file header says the hook "exits fast" and that "the common case … bails after one or two greps over stdin". This path contradicts that, and it runs even though the nudge text is retired: the meter still classifies every Bash call.

Measurement

The mirrored block alone, extracted from the installed hook and called on a synthetic line of a characters with spaces (no ripwire in it):

Command length bash 3.2.57 (macOS /bin/bash) bash 5.3.20 (Homebrew)
2,000 chars 2.9 s 1.7 s
4,000 chars 20.6 s 12.4 s
8,000 chars 155.4 s 93.6 s

Each doubling costs about 7.5-8x on both shells, so a newer bash lowers the constant by about 1.7x and leaves the growth unchanged. At that rate a 16 KB command takes about 20 minutes on bash 3.2 and about 12 on bash 5.3. The hook's #!/usr/bin/env bash takes whichever bash comes first on PATH, and on a stock Mac that is 3.2.

Reproduction

awk '/BEGIN MIRRORED BLOCK rw_is_ripwire_call/,/END MIRRORED BLOCK rw_is_ripwire_call/' \
  hooks/ripwire-nudge.sh > /tmp/rwlex.sh
for n in 2000 4000 8000; do
  s=$(head -c $n /dev/zero | tr '\0' 'a' | fold -w 60 | tr '\n' ' ')
  /usr/bin/time -p bash -c '. /tmp/rwlex.sh; rw_is_ripwire_call "$1"' _ "$s"
done

Cause

The lexer takes one character per iteration and rebuilds the remaining line each time:

rw_c="${rw_line%"${rw_line#?}"}"
rw_line="${rw_line#?}"

${rw_line#?} copies the rest of the line (O(n)), and ${rw_line%"${rw_line#?}"} builds that copy again and then matches it as a suffix pattern against the whole line. That suffix match is itself super-linear on both bash versions measured, which gives the roughly O(n³) total the table shows.

The same loop sits in all three copies of the mirrored block, so this one report covers all of them:

  • hooks/ripwire-nudge.sh (the PreToolUse meter, where the stall was observed)
  • hooks/ripwire-claude-route.sh
  • hooks/ripwire-codex-route.sh

The route hooks were not timed separately; they run the same code on the command they are handed.

Suggested fix

  1. A cheap early exit, POSIX and exact: a line that does not contain the substring ripwire cannot hold a ripwire call, so case "$1" in *ripwire*) ;; *) return 1 ;; esac at the top of rw_is_ripwire_call removes the cost for almost every command.
  2. For the remaining lines, a length cap (for example, lines over a few KB are scored as unclassified), or a lexer that does not rewrite the string per character (a single awk pass over the line, one fork).

Environment

  • ripwire 0.6.2 (built_from=15a20855c); the mirrored block is byte-identical to hooks/ripwire-nudge.sh on main today
  • macOS 27.0, arm64
  • GNU bash 3.2.57 (/bin/bash, which /usr/bin/env bash resolved to when the stall happened) and GNU bash 5.3.20 (Homebrew)
  • Claude Code 2.1.280, hook installed with skills/install.sh --hook

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions