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
andView
. - the model’s
Update
method consumesMessage
and producesCommands
. - synchronously, Bubbletea does in an event loop:
- passes each
Message
(those produced byCommands
, and also those produced by user input) as input to Update. - displays the resulting
View
. - queues any additional
Commands
produced byUpdate
for the next iteration.
- passes each
- asynchronously, Bubbletea reads input from the user
and produces
Messages
. - also asynchronously, all
Commands
(produced byInit
andUpdate
) are translated intoMessages
.
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 toUpdate
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 allCommands
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:
When the
tea.Quit
command is produced byUpdate
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 resultingMessages
.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 inputMessages
.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.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, theseMessages
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 fromUpdate
(run by Bubbletea) toGetLine
(run by the main program), to tell it that one input string has been completed.cont
communicates fromGetLine
back toUpdate
, 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.