Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '1.21'
go-version: '1.25'

- name: Build
run: go build -v ./...
Expand All @@ -42,7 +42,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '1.21'
go-version: '1.25'

- name: Build
run: go build -v ./...
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
console.wiki
.gemini
.claude
11 changes: 7 additions & 4 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ type Commands func() *cobra.Command

// SetCommands requires a function returning a tree of cobra commands to be used.
func (m *Menu) SetCommands(cmds Commands) {
m.mutex.RLock()
defer m.mutex.RUnlock()
m.mutex.Lock()
defer m.mutex.Unlock()
m.cmds = cmds
}

Expand All @@ -31,6 +31,9 @@ func (m *Menu) SetCommands(cmds Commands) {
// If "windows" is used as the argument here, all windows commands for the current
// menu are subsequently hidden, until ShowCommands("windows") is called.
func (c *Console) HideCommands(filters ...string) {
c.mutex.Lock()
defer c.mutex.Unlock()

next:
for _, filt := range filters {
for _, filter := range c.filters {
Expand All @@ -50,8 +53,8 @@ next:
// Use this function if you have previously called HideCommands("filter") and want
// these commands to be available back under their respective menu.
func (c *Console) ShowCommands(filters ...string) {
c.mutex.RLock()
defer c.mutex.RUnlock()
c.mutex.Lock()
defer c.mutex.Unlock()

updated := make([]string, 0)

Expand Down
23 changes: 20 additions & 3 deletions completer.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,12 @@ func (c *Console) complete(input []rune, pos int) readline.Completions {
comps = comps.Prefix(prefixComp)
comps.PREFIX = prefixLine

// Finally, reset our command tree for the next call.
// Finally, reset our command tree for the next call. Only the commands need
// regenerating here: the prompt is already bound and no command output was
// produced, so the full resetPreRun would just be wasted work per keystroke.
// (resetCommands already re-hides filtered commands.)
completer.ClearStorage()
menu.resetPreRun()
menu.hideFilteredCommands(menu.Command)
menu.resetCommands()

return comps
}
Expand Down Expand Up @@ -115,6 +117,21 @@ func (c *Console) justifyCommandComps(comps readline.Completions) readline.Compl

// highlightSyntax - Entrypoint to all input syntax highlighting in the Wiregost console.
func (c *Console) highlightSyntax(input []rune) string {
// Serve a memoized result when the input has not changed since the last
// render. The cache is cleared whenever the command tree is regenerated,
// so a stale tree can never produce a stale highlight.
key := string(input)
if cached := c.hlCache.Load(); cached != nil && cached.input == key {
return cached.output
}

highlighted := c.computeHighlight(input)
c.hlCache.Store(&highlightCache{input: key, output: highlighted})

return highlighted
}

func (c *Console) computeHighlight(input []rune) string {
// Split the line as shellwords
args, unprocessed, err := line.Split(string(input), true)
if err != nil {
Expand Down
68 changes: 68 additions & 0 deletions concurrency_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package console

import (
"errors"
"sync"
"testing"

"github.com/spf13/cobra"
)

// TestConcurrentStateAccess stresses the console's shared state (filters, the
// menus map, and per-menu interrupt handlers) from many goroutines at once.
//
// It is meant to be run with the race detector (`go test -race`). Before the
// locking fixes, these paths mutated maps/slices under a read lock (or no lock
// at all), which the detector flags and which can panic on concurrent map
// writes in production.
func TestConcurrentStateAccess(t *testing.T) {
c := New("test")

// Give the active menu a small command tree so that ActiveFiltersFor has
// something to recurse over while filters are being mutated concurrently.
menu := c.ActiveMenu()
menu.SetCommands(func() *cobra.Command {
root := &cobra.Command{Use: "root"}
child := &cobra.Command{
Use: "child",
Annotations: map[string]string{CommandFilterKey: "filterA,filterB"},
Run: func(*cobra.Command, []string) {},
}
root.AddCommand(child)
return root
})
menu.resetPreRun()

errInt := errors.New("interrupt")

const workers = 64

var wg sync.WaitGroup
wg.Add(workers)

for i := 0; i < workers; i++ {
go func(i int) {
defer wg.Done()

// Filters: concurrent writers (Hide/Show) and readers (ActiveFiltersFor).
c.HideCommands("filterA", "filterB")
c.ShowCommands("filterA")

m := c.ActiveMenu()
for _, cmd := range m.Command.Commands() {
_ = m.ActiveFiltersFor(cmd)
}

// Menus map: concurrent creation and lookup.
_ = c.NewMenu("menu")
_ = c.Menu("menu")
_ = c.ActiveMenu()

// Interrupt handlers map: concurrent writers.
m.AddInterrupt(errInt, func(*Console) {})
m.DelInterrupt(errInt)
}(i)
}

wg.Wait()
}
108 changes: 73 additions & 35 deletions console.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package console

import (
"fmt"
"os"
"strings"
"sync"
"sync/atomic"

"github.com/reeflective/readline"

Expand All @@ -12,6 +14,12 @@ import (
"github.com/reeflective/readline/inputrc"
)

// highlightCache holds a memoized syntax-highlighting result for one input.
type highlightCache struct {
input string
output string
}

// Console is an integrated console application instance.
type Console struct {
// Application
Expand All @@ -21,34 +29,56 @@ type Console struct {
cmdHighlight string // Ansi code for highlighting of command in default highlighter. Green by default.
flagHighlight string // Ansi code for highlighting of flag in default highlighter. Grey by default.
menus map[string]*Menu // Different command trees, prompt engines, etc.
current *Menu // Cached pointer to the active menu (guarded by mutex).
filters []string // Hide commands based on their attributes and current context.
isExecuting bool // Used by log functions, which need to adapt behavior (print the prompt, etc.)
isExecuting atomic.Bool // Used by log functions, which need to adapt behavior (print the prompt, etc.)
printed bool // Used to adjust asynchronous messages too.
mutex *sync.RWMutex // Concurrency management.

// hlCache memoizes the last syntax-highlighting result. The highlighter is
// called on every render (even when only the cursor moved), so caching the
// output for an unchanged input avoids re-splitting and re-walking the
// command tree. It is invalidated whenever the command tree is regenerated
// (see Menu.regenerate), so the input alone is a sufficient key.
hlCache atomic.Pointer[highlightCache]

// Execution

// Leave an empty line before executing the command.
// This is the console-wide default; a menu may override it with
// Menu.SetNewlineBefore.
NewlineBefore bool

// Leave an empty line after executing the command.
// Note that if you also want this newline to be used when logging messages
// with TransientPrintf(), Printf() calls, you should leave this to false,
// and add a leading newline to your prompt instead: the readline shell will
// know how to handle it in all situations.
// This is the console-wide default; a menu may override it with
// Menu.SetNewlineAfter.
NewlineAfter bool

// Leave empty lines with NewlineBefore and NewlineAfter, even if the provided input was empty.
// Empty characters are defined as any number of spaces and tabs. The 'empty' character set
// can be changed by modifying Console.EmptyChars
// This field is false by default.
// This is the console-wide default; a menu may override it with
// Menu.SetNewlineWhenEmpty.
NewlineWhenEmpty bool

// Characters that are used to determine whether an input line was empty. If a line is not entirely
// made up by any of these characters, then it is not considered empty. The default characters
// are ' ' and '\t'.
// This is the console-wide default; a menu may override it with
// Menu.SetEmptyChars.
EmptyChars []rune

// Signals is the set of OS signals the console traps while a command is
// running. When one is received, the running command's context is
// cancelled (see StartContext for the cancellation model). If empty, the
// console defaults to SIGINT, SIGTERM and SIGQUIT.
Signals []os.Signal

// PreReadlineHooks - All the functions in this list will be executed,
// in their respective orders, before the console starts reading
// any user input (ie, before redrawing the prompt).
Expand Down Expand Up @@ -91,6 +121,7 @@ func New(app string) *Console {
// Each menu is created with a default prompt engine.
defaultMenu := console.NewMenu("")
defaultMenu.active = true
console.current = defaultMenu

// Set the history for this menu
for _, name := range defaultMenu.historyNames {
Expand All @@ -109,6 +140,7 @@ func New(app string) *Console {

// Defaults
console.EmptyChars = []rune{' ', '\t'}
console.Signals = append([]os.Signal(nil), defaultTrapSignals...)

return console
}
Expand Down Expand Up @@ -154,8 +186,8 @@ func (c *Console) SetDefaultFlagHighlight(seq string) {
// well as some specific items like history sources, prompt
// configurations, sets of expanded variables, and others.
func (c *Console) NewMenu(name string) *Menu {
c.mutex.RLock()
defer c.mutex.RUnlock()
c.mutex.Lock()
defer c.mutex.Unlock()
menu := newMenu(name, c)
c.menus[name] = menu

Expand All @@ -164,16 +196,13 @@ func (c *Console) NewMenu(name string) *Menu {

// ActiveMenu - Return the currently used console menu.
func (c *Console) ActiveMenu() *Menu {
c.mutex.Lock()
defer c.mutex.Unlock()

return c.activeMenu()
}

// Menu returns one of the console menus by name, or nil if no menu is found.
func (c *Console) Menu(name string) *Menu {
c.mutex.Lock()
defer c.mutex.Unlock()
c.mutex.RLock()
defer c.mutex.RUnlock()

return c.menus[name]
}
Expand All @@ -184,33 +213,39 @@ func (c *Console) Menu(name string) *Menu {
// are bound to this menu name, the current menu is kept.
func (c *Console) SwitchMenu(menu string) {
c.mutex.Lock()

target, found := c.menus[menu]
c.mutex.Unlock()
current := c.current

if found && target != nil {
// Only switch if the target menu was found.
current := c.activeMenu()
if current != nil && target == current {
return
}
// Only switch if the target menu was found and is not already current.
if !found || target == nil || target == current {
c.mutex.Unlock()
return
}

if current != nil {
current.active = false
}
if current != nil {
current.active = false
}

target.active = true
target.active = true
c.current = target

// Remove the currently bound history sources
// (old menu) and bind the ones peculiar to this one.
c.shell.History.Delete()
c.mutex.Unlock()

for _, name := range target.historyNames {
c.shell.History.Add(name, target.histories[name])
}
// The following touches the shell and regenerates the menu commands,
// which itself reacquires c.mutex (history/filters): it must run with
// the lock released to avoid a self-deadlock.

// Regenerate the commands, outputs and everything related.
target.resetPreRun()
// Remove the currently bound history sources
// (old menu) and bind the ones peculiar to this one.
c.shell.History.Delete()

for _, name := range target.historyNames {
c.shell.History.Add(name, target.histories[name])
}

// Regenerate the commands, outputs and everything related.
target.resetPreRun()
}

//
Expand All @@ -224,18 +259,20 @@ func (c *Console) SwitchMenu(menu string) {
// If this function is called while a command is running, the console will simply print the log
// below the line, and will not print the prompt. In any other case this function works normally.
func (c *Console) TransientPrintf(msg string, args ...any) (n int, err error) {
if c.isExecuting {
if c.isExecuting.Load() {
return fmt.Printf(msg, args...)
}

newlineAfter := c.activeMenu().newlineAfter()

// If the last message we printed asynchronously
// immediately precedes this new message, move up
// another row, so we don't waste too much space.
if c.printed && c.NewlineAfter {
if c.printed && newlineAfter {
fmt.Print("\x1b[1A")
}

if c.NewlineAfter {
if newlineAfter {
msg += "\n"
}

Expand All @@ -250,7 +287,7 @@ func (c *Console) TransientPrintf(msg string, args ...any) (n int, err error) {
// If this function is called while a command is running, the console will simply print the log
// below the line, and will not print the prompt. In any other case this function works normally.
func (c *Console) Printf(msg string, args ...any) (n int, err error) {
if c.isExecuting {
if c.isExecuting.Load() {
return fmt.Printf(msg, args...)
}

Expand Down Expand Up @@ -294,10 +331,11 @@ func (c *Console) setupShell() {
}

func (c *Console) activeMenu() *Menu {
for _, menu := range c.menus {
if menu.active {
return menu
}
c.mutex.RLock()
defer c.mutex.RUnlock()

if c.current != nil {
return c.current
}

// Else return the default menu.
Expand Down
Loading
Loading