Skip to content

fix(expander): walk nested spans inside a double-quoted word - #192

Merged
LESdylan merged 3 commits into
developfrom
fix/nested-cmdsub-quote
Nov 4, 2026
Merged

LESdylan merged 3 commits into
developfrom
fix/nested-cmdsub-quote

Conversation

@LESdylan

@LESdylan LESdylan commented Nov 4, 2026

Copy link
Copy Markdown
Member

Closes #190

Problem

sh_skip_quoted read a double-quoted span flat, so $(a "$(tr -d '"')" b) stopped on the inner quote and the whole outer substitution stayed in the word as literal text. No error, exit 0, the command never ran, and hellish -n passed it. Only an odd count of quote bytes shows it.

Fix

sh_skip_quoted delegates to span_skip, the scanner that already walks these spans. No new walker, and the `...` and ${...} neighbours are fixed with it.

Tests

  • tests/cmdsub_nested_quote: 18 cases against bash, registered in tests/tester. 13 of them fail on 3.2.0.
  • make test against the pinned bash 5.3.9: 5381/5381.
  • make all debug build (-Werror): no warnings.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 01e507c9-f6f0-4139-9054-2b4f2c03bdcb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

LESdylan and others added 3 commits November 4, 2026 15:44
readline 8.3 made rl_free_line_state free the context of those three
readers on SIGINT, on the bet that an application's handler longjmps and
the reader never runs again. bash does, and readline83-003 taught
readline's own rl_getc to abort to the top level for handlers that
return. rl_getc_hook replaces that rl_getc and hands the reader EOF, so
the reader unwound through a freed `cxt`. On readline 8.3 at Debian's
patch level:

    freed by:  rl_getc_hook -> rl_take_signals -> rl_check_signals
                 -> _rl_handle_signal -> rl_free_line_state
                 -> _rl_state_sigcleanup -> _rl_isearch_cleanup
                 -> _rl_scxt_dispose
    double free: rl_search_history -> _rl_isearch_cleanup
                 -> _rl_scxt_dispose

ASan reports the double free for ^R and M-x. Under a readline built with
ASan all three readers report a heap-use-after-free WRITE first, in
_rl_search_getchar. A release build takes a SIGSEGV on ^R, 3 runs of 3,
which is the login shell gone. The `ps2` failures in CI were the same
crash: that scenario shares the session ^R killed.

Guarding only rl_take_signals' own look is not enough, and the tests say
so: readline also looks at its deferred signal at the end of every
rl_redisplay, inside the search, and a ^C sent with the keys of a ^R came
through there. rl_intr.c puts a filter in front of readline's SIGINT
handler instead. While such a reader is up the signal is parked, the
reader gets EOF and unwinds with its context intact, the path every
readline before 8.3 took, and back in the main loop the ^C is replayed
into readline. Outside those readers the filter only forwards, so ^C
anywhere else is unchanged. Blocking SIGINT from the key's return to the
next wait would also work, but needs lifting around every widget and
completion, and a child forked on a path that misses it inherits it.

prompt_interrupt_test.py gains nsearch, mx and redraw;
rl_read_race_test.py gains nsearch and M-x. 48 and 34 ok on readline
8.1.2, 8.2.13 and 8.3 at patch levels 0, 3 and 6, and against a readline
built with ASan. On the old code the same files fail on 8.3.3, the redraw
scenario included.

Closes #194

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
prompt_row_writes_test.py failed on ubuntu-26.04 with "7 of 13 rows were
whole inside a single write()". No row was split. The trace has
`GREEN<glyph> RESET \r GREEN<glyph> RESET` in one write(), every prompt:
readline 8.3 before readline83-004 reprints a prompt holding multibyte
characters from column 0, deciding on a screen position where it needs a
buffer offset. Debian's 8.3-4 carries patches 001 to 003. Built here with
006 the second copy is gone and the old test passes; built at 003 it fails
the way CI does.

The invariant is that every row is whole in one write(). The test counted
the writes holding a row and compared them with the rows, which assumes
one row per write. It counts the rows now, so one really cut in two is
still a failure, and it is not a change in what is pinned. The redraw
itself is readline's, costs 28 bytes a prompt, lands on the same cells,
and ends with 004.

Refs #194

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sh_skip_quoted read a double-quoted span flat, to the next `"`. Inside a
$(...), that span can hold a nested $(...), ${...} or `...` with quotes of
its own, so find_cmd_sub_end stopped on the inner quote:

    x=$(printf %s "$(echo b | tr -d '"')" c)   # bash: bc
                                               # hellish: $(printf %s "b" c)

The `'` after that wrong stop opened a span with no end, depth never reached
zero, and the outer substitution stayed in the word as literal text. No error,
exit 0, the command never ran, and `hellish -n` passes it. Only an odd count of
quote bytes inside shows it: an even one balances by accident, which is why
$(a "$(b "$c")") always worked.

Found by a born2root provisioner: a readiness check written this way failed on
every first boot, which runs it under hellish, and passed on every retry, which
runs it under bash.

sh_skip_quoted now delegates to span_skip, the scanner that already walks these
spans for the word expander, so no new walker is added. tests/cmdsub_nested_quote
has 18 cases compared against bash; 13 of them fail on 3.2.0.

Left alone, as a separate concern: a `case` with its `)` inside a nested
substitution, x=$(echo "$(case a in a) echo "q'r";; esac)"), is refused earlier
by the lexer's advance_cmdsub, which has no case handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@LESdylan
LESdylan force-pushed the fix/nested-cmdsub-quote branch from f51018e to 28203eb Compare November 4, 2026 14:45
@LESdylan
LESdylan merged commit 28203eb into develop Nov 4, 2026
37 checks passed
@LESdylan

LESdylan commented Nov 4, 2026

Copy link
Copy Markdown
Member Author

Landed on develop by fast-forward as 28203eb, which is why GitHub shows this PR as merged. The branch was rebased onto fix/readline-83-interrupt first, so develop also gained 397a151 and 8fc4bcf, the fix for #194. CI on 28203eb before landing: 34 success, 3 skipped.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant