Skip to content

Upgrade readline to v1.2.0, fix data races/signal leak, add tests, perf & features#79

Merged
maxlandon merged 12 commits into
mainfrom
dev
Jun 1, 2026
Merged

Upgrade readline to v1.2.0, fix data races/signal leak, add tests, perf & features#79
maxlandon merged 12 commits into
mainfrom
dev

Conversation

@maxlandon
Copy link
Copy Markdown
Member

Summary

Upgrades dependencies (notably readline v1.2.0, which fixes the bottom-of-terminal prompt overlap from #78), fixes a set of concurrency/correctness bugs, trims two hot paths, adds four feature improvements, and takes the package from 0 tests to 101 passing cases (all green under -race).

Built on top of the existing dev work (the example: commit demonstrating readline hint lanes was already on dev).

Dependencies

  • reeflective/readline v1.1.4 → v1.2.0 (fixes Prompt string overlaps with input at the bottom #78 prompt overlap)
  • carapace v1.7.1 → v1.11.6, cobra v1.8.1 → v1.10.2, pflag v1.0.6 → v1.0.10, mvdan.cc/sh/v3 v3.7.0 → v3.13.1, golang.org/x/exp refreshed
  • go directive → 1.25.0 (required by readline v1.2.0 and mvdan.cc/sh v3.13.1)
  • CI: bumped go.yml to Go 1.25 to match (1.21 would no longer build)

Bug fixes

  • Signal leak: monitorSignals registered a channel via signal.Notify on every command and never released it; now defer signal.Stop.
  • Data races: isExecuting is now an atomic.Bool; maps/slices (filters, interruptHandlers, histories, cmds) that were mutated under RLock (or no lock) now use Lock; activeMenu() no longer reads the menus map unlocked (cached pointer).
  • Latent deadlock: ActiveFiltersFor took a write lock and recursed while holding it; now snapshots filters under RLock and walks lock-free. SwitchMenu releases the lock before resetPreRun.
  • Alias highlighting: commands invoked via an alias were never highlighted (a break skipped the highlight branch).

Performance

  • Memoize syntax highlighting on unchanged input (invalidated whenever the command tree regenerates).
  • Completion uses a lighter resetCommands (no prompt rebind / output reset / redundant re-filter per keystroke).

Features

  • Robust interrupt matching: errors.Is first, message-equality fallback for the historical errors.New(...) pattern.
  • Cancellation model documented on StartContext (trapped signal cancels cmd.Context(); a long-running command must observe it).
  • Per-menu newline overrides: Menu.SetNewlineBefore/After/WhenEmpty/SetEmptyChars (unset → inherits the console default).
  • Configurable trapped signals: new Console.Signals (defaults to SIGINT/SIGTERM/SIGQUIT).

Tests (new — 101 cases, all green under -race)

  • Parsers: internal/line (Parse/Split/AcceptMultiline/IsEmpty/…) and internal/completion (SplitArgs/splitCompWords/adjustQuotedPrefix/sanitizeArgs).
  • Highlighting (command + flags), strutil.Template, menu filtering (ActiveFiltersFor/CheckIsAvailable + inheritance), highlight-cache invalidation.
  • A -race concurrency stress test.
  • Feature coverage: interrupt matching, per-menu newline resolution, signal config, and the pre/post-run newline display matrix.

Verification

go build ./..., go vet ./..., and go test -race ./... all pass locally.

Closes #78.

🤖 Generated with Claude Code

maxlandon and others added 12 commits June 1, 2026 03:30
Add a "readline features" command group and a passive hint provider to the
example console, exercising the new reeflective/readline APIs:

  - setupReadlineHints: registers rl.Hint.SetProvider, a passive hint computed
    from the current line (shows the matched command's short description).
  - notify: pushes async status updates into the transient hint lane from a
    goroutine (rl.Hint.SetTransient/ClearTransient) — the idle shell repaints
    on its own via the async-refresh wake.
  - hint set/clear: set or clear a sticky transient hint synchronously.
  - scan: async completions — a background "discovery" grows the candidate set
    and calls rl.RefreshCompletions() to rebuild the open menu in place, so
    hosts appear live with no keystroke.

The feature commands are registered after the generic file-completion loop so
their custom (async) completers are not overwritten. Includes cursor.cast, a
screencast used while diagnosing the hint-area drift bug.

Note: builds against the local readline via the go workspace; needs a readline
release + go.mod bump to build standalone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Upgrade direct dependencies:
- reeflective/readline v1.1.4 -> v1.2.0 (fixes the bottom-of-terminal
  prompt overlap reported in #78, via the NewlineBefore/After fix)
- carapace-sh/carapace v1.7.1 -> v1.11.6
- spf13/cobra v1.8.1 -> v1.10.2
- spf13/pflag v1.0.6 -> v1.0.10
- mvdan.cc/sh/v3 v3.7.0 -> v3.13.1
- golang.org/x/exp -> latest

The go directive moves to 1.25.0, required by both readline v1.2.0 and
mvdan.cc/sh v3.13.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concurrency:
- isExecuting was written under RLock and read with no lock at all; make
  it an atomic.Bool so all accesses are correct and lock-free.
- Several maps/slices (filters, interruptHandlers, histories, cmds) were
  mutated under RLock, and HideCommands took no lock at all. Writers now
  take Lock; readers (Menu, ActiveFiltersFor) take RLock.
- activeMenu() iterated the menus map with no lock. Cache the active menu
  in a pointer maintained under Lock, and read it under RLock (also drops
  the per-call linear scan).
- handleInterrupt ranged the handler map unlocked while a handler could
  mutate it; snapshot matching handlers under RLock, then run them after
  releasing.

Deadlock:
- ActiveFiltersFor held a write lock and recursed into itself while
  holding it (latent self-deadlock on the non-reentrant mutex, plus
  per-command render contention). Snapshot the filters once under RLock,
  then walk the command tree lock-free.
- SwitchMenu now releases c.mutex before resetPreRun, which reacquires it.

Signal leak:
- monitorSignals registered a channel via signal.Notify on every command
  execution and never released it, leaking a channel per command for the
  process lifetime. execute now defers signal.Stop(sigchan).

Highlighting:
- A command invoked through an alias was never highlighted because the
  alias branch broke out of the loop before the highlight block. Fold the
  alias check into cmdFound.

Tests:
- concurrency_test.go stresses the shared state from many goroutines and
  passes under -race.
- internal/line/highlight_test.go covers alias highlighting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Drop the unused completion.UnescapeValue; the live completion path uses
  line.UnescapeValue (completer.go).
- Remove the leftover `// menu := app.activeMenu()` comment in the prompt.
- Simplify the BindPrompt primary wrapper to a plain nil-guard and fix its
  stale comment: newline handling now lives in readline, not here.
- Drop the redundant `buf == ""` branch (len(buf) == 0 already covers it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Table-driven coverage for the shell-parsing core:

internal/line:
- Parse: comment stripping, quoting, whitespace collapse, unterminated
  quotes (error).
- Split: words, quotes, escaped spaces, and the unterminated
  quote/escape remainder paths.
- AcceptMultiline: complete vs. unterminated-quote/escape continuation.
- IsEmpty, TrimSpaces, UnescapeValue.

internal/completion:
- splitCompWords: words/quotes plus remainder + error on unterminated
  quote/escape.
- adjustQuotedPrefix: the quote/escape prefix derivation.
- sanitizeArgs: newline/tab/escaped-space normalization.
- SplitArgs: empty line, partial word, trailing-space new word,
  unterminated-quote prefix handling, and ANSI color stripping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two hot paths run on (nearly) every keystroke. Reduce their per-call cost
without changing observable behavior:

- Syntax highlighting: memoize the last result keyed on the input string.
  readline calls the highlighter on every render (including cursor-only
  moves), so an unchanged line no longer re-splits the input and re-walks
  the command tree. The cache is invalidated whenever the command tree is
  regenerated (Menu.regenerate), so a stale tree can't yield a stale
  highlight; the key is stored/loaded via atomic.Pointer to stay race-free.

- Completion reset: complete() previously called the full resetPreRun()
  (which rebuilds the command tree, re-binds the prompt, and resets the
  output buffer) and then redundantly re-hid filtered commands. Split out
  a lighter resetCommands() that only regenerates + re-filters the tree:
  the prompt is already bound and no command output is produced during
  completion, so that extra work was wasted per keystroke. resetPreRun now
  delegates to the same shared regenerate() core.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cast recording is no longer used by the example app.
… cache

- internal/line: HighlightCommandFlags (flags colored, operands passed
  through).
- internal/strutil: Template rendering, the trim func, and ranging.
- console: ActiveFiltersFor and CheckIsAvailable, including parent-subtree
  filter inheritance and activation/deactivation via Hide/ShowCommands.
- console: highlight-cache memoization correctness and its invalidation on
  command-tree regeneration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cel docs

- Interrupt matching: handleInterrupt now matches the incoming error against
  registered handlers with errors.Is first (so sentinel and wrapped errors
  work), then falls back to message comparison for the historical
  errors.New(...) pattern. Resolves the long-standing TODO in interrupt.go.

- Per-menu newline behavior: NewlineBefore/After/WhenEmpty and EmptyChars are
  console-wide defaults that a menu can now override via SetNewlineBefore,
  SetNewlineAfter, SetNewlineWhenEmpty and SetEmptyChars (nil/unset inherits
  the console value). The pre/post-run display and TransientPrintf resolve
  through the active menu.

- Configurable trapped signals: new Console.Signals field selects which OS
  signals cancel a running command. It defaults to SIGINT/SIGTERM/SIGQUIT
  (shared defaultTrapSignals), and monitorSignals registers exactly that set.

- Cancellation model: StartContext now documents how a trapped signal cancels
  the command's cmd.Context(), and that a long-running command must observe
  that cancellation itself (cobra cannot preempt it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- handleInterrupt: errors.Is (wrapped), message fallback, and no-match.
- Menu newline/empty-char overrides: inheritance, override precedence, and
  clearing back to inheritance.
- Console.Signals: default set is populated, and monitorSignals honors a
  custom set (SIGUSR1 raise, unix-only build tag).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture os.Stdout and assert the displayPreRun/displayPostRun newline output
across the enabled × when-empty × (empty/non-empty input) matrix, plus a case
proving a per-menu newline override is honored by the display path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The module now requires go 1.25.0 (readline v1.2.0 and mvdan.cc/sh v3.13.1),
so the CI's pinned Go 1.21 would fail to build. Bump both the unix and
windows jobs to Go 1.25.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maxlandon maxlandon merged commit da2aa57 into main Jun 1, 2026
4 checks passed
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.

Prompt string overlaps with input at the bottom

1 participant