Abstract

In the Bubbline project we are trying to build readline-like semantics on top of Charm’s Bubbletea.

As of Bubbletea v0.23, we discovered this is currently impossible to do in a way that preserves input continuity reliably across readline invocations.

This result is general. Any Bubbletea-based program or library, including Bubbline and Charm’s flagship gum toolkit, suffers from this flaw. It makes Bubbletea only suitable for interactive use with relatively slow human input. Integration with unit testing frameworks or scripts that control the execution of the enclosing unix process is doomed to remain brittle and unreliable.

An improvement to this situation would require extending the execution model of Bubbletea with new primitives.

Introduction

The Bubbline project was built with the aim to replace go-libedit as the main line editor in the CockroachDB SQL shell.

Unix line editors, of which GNU readline is probably the most famous, offer a conceptually simple service: ask the user to provide some textual input, and then provide this input to the program.

After the initial implementation, and subsequent integration in CockroachDB, we found that the Bubbline editor performed well with human users but that it was impossible to integrate the resulting SQL shell in its unit test suite: tests were failing non-deterministically.

More specifically, the input provided by the test framework was either lost or garbled. In the following blog post, we find out how Bubbletea is responsible for this problem.

Line editor basics

The API for a line editor is always about the same:

func GetLine(prompt string) (string, error)

That is:

  • the main program transfers control to the line editor. This suspends execution of the program.
  • the editor takes control of the terminal, and provides an interactive UX to the user facing the terminal.
  • when the user completes their input (typically, using the Enter key), the editor shuts down, and returns the input as a string to the program which then resumes execution.

In-between calls to the line editor, the program is free to interact with the terminal as it wishes: the editor only owns the terminal when the API function is being called.

An important property of line editors is input continuity: if a user types something on their keyboard before the program transfers control to the editor API, that input will be picked up when the editor API is called.

A secondary, also important property is graceful input transfer: after the user completes one input (typically with the enter key), any further user input remains in the terminal input buffer so that the program can find and consume it outside of the line editor, or to subsequent editor calls.

As we will find out below, Bubbletea v0.23 is currently unable to provide input continuity nor graceful input transfer.

Bubbletea basics

Bubbletea is inspired by the Elm component model:

  • the developer provides a model with 3 methods: Init, Update and View.
  • the model’s Update method consumes Message and produces Commands.
  • synchronously, Bubbletea does in an event loop:
    • passes each Message (those produced by Commands, and also those produced by user input) as input to Update.
    • displays the resulting View.
    • queues any additional Commands produced by Update for the next iteration.
  • asynchronously, Bubbletea reads input from the user and produces Messages.
  • also asynchronously, all Commands (produced by Init and Update) are translated into Messages.

The control flow inside Bubbletea has a “main event loop”, which calls Update and View one after the other and displays the View results. Then, asynchronously, it reads external input to produce Messages, and also separately converts Commands into Messages.

Of note about the order of processing:

  • the order of Messages produced by reading external input is preserved: they are presented to Update in the same order as they were read, using an ordered FIFO buffer. This is obviously a good thing: the user would be confused if their keystrokes were processed out of order.
  • the order of commands produced by Update and Init is not preserved: each Command is converted into zero or more Message using its own asynchronous task. This non-deterministic reordering of all Commands is why I call this conversion the “command tumbler” in the diagram above.

Bubbline basics

The first version of Bubbline, current as of this writing, is constructed as follows:

  • when Bubbline is not running (GetLine is not being called), nothing is happening.
  • when Bubbline is activated (program calls GetLine), it does the following:
    • starts the Bubbletea framework with an Editor model. This takes control of the terminal.
    • waits for the Bubbletea framework to complete execution, caused by the processing of a tea.Quit command. This also releases control of the terminal.
    • after the Bubbletea framework has terminated, it plucks the completed string from inside the Editor model and returns it to the surrounding program.

In other words, Bubbline is inverting control away from Bubbletea, taking it away from the Bubbletea event loop and places it back into the hands of the surrounding program.

Troubles ahead: input corruption

With a slow input at the keyboard, all seems to work well.

Alas, the moment the input comes fast, for example in unit tests, everything breaks down.

What is happening really?

Remember, Bubbletea reads user input and translates commands asynchronously. This causes 3 separate problems:

  1. When the tea.Quit command is produced by Update as a result of seeing the Enter key message, the user input function is still running asynchronously in the background.

    So that input function can and will consume more external input from the terminal beyond the Enter key that caused the Update function to consider the input complete, and queue some resulting Messages.

  2. Meanwhile, the tea.Quit command is produced by Update but it does not result directly in Bubbletea shutdown. Instead, it goes into the “command tumbler” which translates it to a quit Message and queues that non-deterministically with the other input Messages.

    While this translation is happening, some of the additional input events read at step (1) can be passed to the model Update method, even though at this point the input for this round is complete already.

    In other words, we are seeing further input “catch up” with tea.Quit and append more text to the first input before the program can consume it.

  3. When Bubbletea finally sees the quit Message, it starts shutting down all the asynchronous tasks. Because of how the code is organized, the async input function may discover it needs to shut down after it has consumed some terminal input, but before it got a chance to queue the input Messages for further processing. In that case, these Messages are simply lost.

Because of these problems, if input comes into the terminal sufficiently fast after the Enter key has been typed, for example if it was pasted/buffered all at once by a test:

  • some of it will corrupt the Editor model (as per (2) above) before the program gets a chance to consume the previous input text;
  • some of it will simply disappear into the aether (per (3) above).

These are both violations of the two desirable properties: there is no guarantee of input continuity nor graceful input transfer.

Sadly, there is nothing that Bubbline can do about this. Not even a “sleep” function call inside the GetLine function would help: this input corruption is a feature of Bubbletea alone.

Simple demonstration program

The following Go unit test demonstrates the problem identified above, using a simple model that remembers the last input key entered before an Enter key, and a test condition that checks for input continuity.

func TestTeaInputPreservedAcrossRestarts1(t *testing.T) {
  const expectedCount = 100

  // Prepare 100 inputs in a single continuous buffer.
  input := strings.Repeat("hello\r", expectedCount)
  inputBuf := bytes.NewBuffer([]byte(input))
  var outBuf bytes.Buffer

  // ed will be our editor.
  ed := &testModel1{}

  var count int
  for count = 0; count < 100; count++ {
    // Reset the editor.
    ed.msg = ""
    // Run the bubbletea interaction until it stops.
    p := tea.NewProgram(ed, tea.WithInput(inputBuf), tea.WithOutput(&outBuf))
    if err := p.Start(); err != nil {
      t.Fatal(err)
    }
    // At this point bubbletea has shut down; get the remaining
    // payload.
    msg := ed.msg

    // Is this what we expect?
    t.Logf("msg: %q", msg)
    if msg != "hello" {
      // No: stop.
      t.Errorf("corrupted input: %q", msg)
      break
    }
  }
  // Did we consume all the input?
  if count != expectedCount {
    t.Errorf("expected %d inputs, got %d", expectedCount, count)
  }
}

type testModel1 struct {
  msg string
}

func (m *testModel1) Init() tea.Cmd { return nil }
func (m *testModel1) Update(imsg tea.Msg) (tea.Model, tea.Cmd) {
  switch msg := imsg.(type) {
  case tea.KeyMsg:
    switch msg.Type {
    case tea.KeyEnter:
      return m, tea.Quit
    default:
      m.msg += string(msg.Runes)
    }
  }
  return m, nil
}
func (m *testModel1) View() string { return "" }

If you run this test, you will see that it simply blocks: the very first Bubbletea activation will succeed in reading the entire input buffer before it shuts down, discarding the remaining input on the way, so that the second iteration of the test loop has no input remaining to consume.

Segue: not just Bubbline is affected

Any tool using Bubbletea will gobble up / lose input from the terminal after the Bubbletea framework has shut down.

This will be especially visible with the gum toolkit, for example when redirecting input from a file into a script calling multiple gum commands in a row.

Attempt at a solution: hijacking the event loop

The main insight when analyzing the cause of the problem is that the Bubbletea shutdown logic is where the input corruption happens.

If we could avoid shutting down Bubbletea, we could perhaps avoid this problem altogether.

So here’s an attempt at a solution: instead of having Bubbline re-start and shut down Bubbletea at every GetLine call, start Bubbletea asynchronously only once, then make GetLine interact with it using FIFO channels.

The idea is like this:

  • start Bubbletea to run completely asynchronously.

  • define a pair of FIFO channels:

    • ready communicates from Update (run by Bubbletea) to GetLine (run by the main program), to tell it that one input string has been completed.
    • cont communicates from GetLine back to Update, to tell it to unblock Bubbletea execution.
  • with careful synchronization over these two channels, we can guarantee that either the Bubbletea event loop or the main program are running at a time, but not both.

    In other words, this design hijacks the Bubbletea event loop to synchronize it cleanly with the main program.

And … this seems to work. Here is an example Go test that demonstrates this design:

func TestTeaInputPreservedAcrossRestarts2(t *testing.T) {
  const expectedCount = 100

  // Prepare 100 inputs in a single continuous buffer.
  input := strings.Repeat("hello\r", expectedCount)
  inputBuf := bytes.NewBuffer([]byte(input))
  var outBuf bytes.Buffer

  // ed will be our editor.
  ed := &testModel2{
    ready: make(chan string),
    cont:  make(chan tea.Cmd),
  }

  // In this test, we start bubbletea just once.
  p := tea.NewProgram(ed, tea.WithInput(inputBuf), tea.WithOutput(&outBuf))
  go func() {
    // The event loop runs asynchronously, in a separate goroutine.
    if err := p.Start(); err != nil {
      os.Exit(1)
    }
  }()

  // Wait for the async goroutine to call our Init() method.
  <-ed.ready

  var count int
  for count = 0; count < 100; count++ {
    // Reset the editor.
    ed.msg = ""
    // Tell our model to resume execution - at this point it is either
    // blocked in Init() or in Update(). This will resume execution
    // in the bubbletea event loop.
    ed.cont <- nil

    // Wait for the next input to be recognized.
    msg := <-ed.ready

    // Is this what we expect?
    t.Logf("msg: %q", msg)
    if msg != "hello" {
      // No: stop.
      // Don't forget to tell our async goroutine to terminate.
      ed.cont <- tea.Quit
      t.Errorf("corrupted input: %q", msg)
      break
    }
  }
  if count != expectedCount {
    t.Errorf("expected %d inputs, got %d", expectedCount, count)
  }
}

type testModel2 struct {
  // msg is the last recognized input.
  msg string

  // cont will deliver instructions from the Test loop
  // above to continue the bubbletea event loop.
  cont chan tea.Cmd
  // ready is written by the Update method below to
  // tell the Test loop that an input is ready.
  ready chan string
}

func (m *testModel2) Init() tea.Cmd {
  m.ready <- ""
  return <-m.cont
}
func (m *testModel2) Update(imsg tea.Msg) (tea.Model, tea.Cmd) {
  switch msg := imsg.(type) {
  case tea.KeyMsg:
    switch msg.Type {
    case tea.KeyEnter:
      // Tell the Test loop we have some input ready.
      m.ready <- m.msg
      // Wait for the Test loop to look at the input
      // and tell us what to do next.
      return m, <-m.cont
    default:
      m.msg += string(msg.Runes)
    }
  }
  return m, nil
}
func (m *testModel2) View() string { return "" }

If you run this unit test, you will see that it passes reliably.

The reason why it passes reliably is that the asynchronous input reader function remains running across all transfers of control between Bubbletea and the test.

So the conversion from the input string to a stream of input Messages is never interrupted and all these Messages are eventually processed by an Update call in the right order.

This is progress! In this way, we certainly get input continuity.

However, there is still trouble ahead.

Missing graceful input transfer

In the design above, the Bubbletea input reader remains running in the background. It can still “gobble up” input from the external input stream.

So when the GetLine call re-takes control of Update and the Bubbletea event loop, some input coming after the Enter key is already processed by the Bubbletea input reader and queued as Bubbletea Messages.

That input is then irremediably lost from the external input stream: the main program cannot find it there any more. It has been “captured” by Bubbletea, only usable by the next GetLine invocation.

This design thus fails to provide graceful input transfer (to the main program).

Although the lack of graceful input transfer might be acceptable for CockroachDB’s SQL shell, it is not acceptable if Bubbline is to become a more general-purpose tool in the Go ecosystem.

Undesirable terminal capture

Although the Go unit test passes reliably, an actual attempt to build Bubbline on top of this design fails spectacularly: after GetLine blocks Update and passes control back to the main program, the terminal has become unusable by the main program.

This is because when the Bubbletea event loop starts, it “takes ownership” of the terminal and puts it into raw mode, hides the cursor and does other initialization suitable for the Bubbletea display function. When GetLine blocks Update, the terminal is still in raw mode and has become unusable for normal I/O.

In order to make progress, we can try to to re-take ownership of the terminal in the main program after blocking Update, and then restore the Bubbletea terminal configuration at the next GetLine invocation.

Seemingly helpfully, Bubbletea provides an API that appears to do what we want: a pair of functions ReleaseTerminal() and RestoreTerminal().

We can extend our test program above to use it. This program is approximately equivalent to the previous one, with a new call to Bubbletea’s ReleaseTerminal() and RestoreTerminal() at the point where our Update function blocks.

func TestTeaInputPreservedAcrossRestarts3(t *testing.T) {
  const expectedCount = 100

  // Prepare 100 inputs in a single continuous buffer.
  input := strings.Repeat("hello\r", expectedCount)
  inputBuf := bytes.NewBuffer([]byte(input))
  var outBuf bytes.Buffer

  // ed will be our editor.
  ed := &testModel3{
    cont:  make(chan tea.Cmd),
    ready: make(chan string),
  }

  // In this test, we start bubbletea just once.
  p := tea.NewProgram(ed, tea.WithInput(inputBuf), tea.WithOutput(&outBuf))

  ed.completeFn = func(msg string) tea.Cmd {
    // Release the terminal, so the Test loop below can do sane I/O.
    p.ReleaseTerminal()
    ed.ready <- msg
    cmd := <-ed.cont
    // Re-take the terminal.
    p.RestoreTerminal()
    return cmd
  }

  go func() {
    // The event loop runs asynchronously, in a separate goroutine.
    if err := p.Start(); err != nil {
      os.Exit(1)
    }
  }()
  // Wait for the async goroutine to call our Init() method.
  <-ed.ready

  var count int
  for count = 0; count < 100; count++ {
    // Reset the editor.
    ed.msg = ""
    // Tell our model to resume execution - at this point it is either
    // blocked in Init() or in Update(). This will resume execution
    // in the bubbletea event loop.
    ed.cont <- nil

    // Wait for the next input to be recognized.
    msg := <-ed.ready

    // Is this what we expect?
    t.Logf("msg: %q", msg)
    if msg != "hello" {
      // No: stop.
      // Don't forget to tell our async goroutine to terminate.
      ed.cont <- tea.Quit
      t.Errorf("corrupted input: %q", msg)
      break
    }
  }
  if count != expectedCount {
    t.Errorf("expected %d inputs, got %d", expectedCount, count)
  }
}

type testModel3 struct {
  // msg is the last recognized input.
  msg string

  // cont will deliver instructions from the Test loop
  // above to continue the bubbletea event loop.
  cont chan tea.Cmd
  // ready is written by the Update method below to
  // tell the Test loop that an input is ready.
  ready chan string

  // completeFn is called when an input has been recognized.
  completeFn func(msg string) tea.Cmd
}
func (m *testModel3) Init() tea.Cmd {
  m.ready <- ""
  return <-m.cont
}
func (m *testModel3) Update(imsg tea.Msg) (tea.Model, tea.Cmd) {
  switch msg := imsg.(type) {
  case tea.KeyMsg:
    switch msg.Type {
    case tea.KeyEnter:
      cmd := m.completeFn(m.msg)
      return m, cmd
    default:
      m.msg += string(msg.Runes)
    }
  }
  return m, nil
}
func (m *testModel3) View() string { return "" }

The previous version was running flawlessly, and this one did not change anything about control flow, so we should expect it passes just as well?

Alas! After one or two successful iterations, the test fails again with some corrupted input. Input continuity has been lost again.

General flaw: loss of input in ReleaseTerminal

The reason for this flaw is tragically simple: Bubbletea’s ReleaseTerminal() stops the input reader function asynchronously.

This mechanism has two separate problems:

  • the input reader function may continue to run for a short while (and consume input) even after ReleaseTerminal has returned, because the termination signal is also delivered asynchronously.

    This violation of input continuity could perhaps be worked around by adding a short “sleep” call after the ReleaseTerminal call; however…

  • the input cancellation is done via the cancelreader library, which unfortunately can consume some of the external input bytes concurrently with receiving the cancellation signal, such that some input bytes get lost when cancellation is processed.

In other words, a limitation inside the cancelreader library causes loss of input during ReleaseTerminal(), in a way that Bubbletea clients cannot work around.

Because ReleaseTerminal() is called in every Bubbletea program, if only during Bubbletea shutdown, this limitation every program in the Bubbletea ecosystem, such as gum, equally.

Way forward: extend Bubbletea

At the heart of the current problem, is the fact that Bubbletea’s input reader function is running asynchronously and loses input bytes or events upon cancellation.

Of note, neither the venerable GNU readline, nor BSD libedit suffer from this flaw. The reason is that they both read external input synchronously, one input event at a time, and handle cancellation using synchronous checks in-between events. Once the editor has decided that input is complete and decides to shut down, the next event has not been read yet and is still available in the external input buffer. These editors, like most, provide input continuity and graceful input transfer natively by avoiding asynchrony while reading input.

Can we achieve the same with Bubbletea?

This would require a couple of architecture changes:

  • first, the input reader function should become “streaming”, reading only one event at a time for consumption by the Bubbletea input loop. This way, the moment Bubbletea stops processing messages, a maximum of one next event has been read.

    I have prepared a patch that achieves this: https://github.com/charmbracelet/bubbletea/pull/569

    However it has not yet been considered for inclusion in Bubbletea.

  • then, the input reader function should not use only one FIFO channel to send Messages to the Bubbletea event loop. With this design, there is always at last one input event already consumed every time the FIFO channel blocks, such as during shutdowns.

    Instead, the input reader should wait for a read token from the event loop which would be provided after the Update call that consumed the last input Message completes. This read token would be generated “in the common case”, but could be blocked if the Update function decides to stop processing input.

  • finally, Bubbletea needs to provide an API to “suspend the event loop”, and gracefully transfer control of the terminal and the input.

    A design for this API does not exist yet. I believe that if the input reader uses tokens like described above, it does not matter whether the reader remains running in the background or gets terminated when the event loop is suspended. However, further experimentation is needed.

Summary

We’ve found that Bubbletea-based programs currently non-deterministically lose input entered on the terminal after they start shutting down, but before they terminate.

This window of time is typically small, less than 200ms, and is thus negligible for human users. However, it makes Bubbletea (as of v0.23) unsuitable for integration in automated scripts or unit tests that deliver input to Bubbletea faster than a human user.

The following Bubbletea issue has been filed to track this situation: https://github.com/charmbracelet/bubbletea/issues/616

There is a way forward though, with careful changes to the architecture of the Bubbletea event loop. It is yet unclear when Bubbletea will be improved in that way.

References

  • Bubbletea, a terminal UI framework.
  • Bubbline, a line editor for Go program based off Bubbletea.
  • gum, a collection of simple Unix command-line utilities to obtain user input on a terminal.
  • The Elm component model.

Like this post? Share on: TwitterHacker NewsRedditLinkedInEmail


Raphael ‘kena’ Poss Avatar Raphael ‘kena’ Poss is a computer scientist and software engineer specialized in compiler construction, computer architecture, operating systems and databases.
Comments

So what do you think? Did I miss something? Is any part unclear? Leave your comments below.


Keep Reading


Reading Time

~14 min read

Published

Category

Programming

Tags

Stay in Touch