Screaming at the Silicon

Archive - Fedi

Cooking the Terminal with Supervisors In Go

I have written a command-line application that relies on an external library for interactivity. It fails to defer a call properly and leaves the terminal in a state where user input fails to echo back. While the first half of the problem is a Go programming issue, this error arises in part because of the call that's not deferred and also in part because of navigating between terminal modes.

There are two types of terminal modes: cooked and raw. A cooked terminal mode is operating system dependent, but each cooked mode provides some standard processing (such as translating line feeds to carriage-return/linefeed pairs). The goal of cooked mode is to handle the OS-level user interactions in such a way that everything continues to behave normally. By contrast, raw mode just forwards inputs along.

My workaround to this issue has me doing a naive implementation (I might be inclined to call it an anti-pattern) of Erlang-style supervisors so that when the program exits, my terminal returns to the cooked state.

My Specific Problem

The yarlson/tap library opens a raw TTY as part of its operation but does not set a defer Close() method. Which normally isn't an issue because I could, in theory, add that myself. The complication arises when tap exits because it calls os.Exit(1), meaning that any defer cleanup() method I might want to impose to handle this issue will not work because os.Exit() explicitly does not call deferred methods when invoked.

What was happening in my case is that I would send the exit signal and the program would exit, and I could still use the terminal, but none of the responses would echo back to the command line, which is expected in raw mode, but after exiting an application, I would expect to return to cooked mode.

While in search of a solution to my issue, I came across Erlang's Supervision Trees and took them as inspiration. My implementation is not nearly as cool as what Erlang came up with. This pattern does not exist out of the box in Go, but what follows is my naive implementation to work around the present issue until the MR that solves my issue in the upstream library is resolved.

To get my terminal backed to cooked mode after the program exits I needed to do two things. I need to get the terminal state and save it for later use and then also isolate the problematic code so that when os.Exit(1) is called, I can gracefully handle that process and reset the terminal state.

I mentioned that there is an open MR that solves this problem. I could use Go's replace method in go.mod to point the yarlson/tap library at the fixed fork. That honestly might be the best solution but that feels messier to me and would rob me the opportunity about learning something and sharing that with others.

Implementing the Workaround

Effectively, we end up with a few functions. In my implementation all of these landed in my main.go because they were crucial to starting the application. I normally like to keep my main.go down to maybe a few constants and a simple main function and then put the actual entry point outside the main package. I do this to get more unit testing ability without monkeying around in main.

The imports for reference.

import (
    "errors"
    "fmt"
    "io"
    "os"
    "os/exec"
    "os/user"
    "time"

    log "github.com/sirupsen/logrus"
    "github.com/urfave/cli"
    "golang.org/x/term"
)

To pull this off, we will need some new constants.

const (
    runWorkerProcess = "RUN_WORKER_PROCESS"
    runWorkerEnabledValue = "1"
)

main.go

func main() {
    if os.Getenv(runWorkerProcess) == runWorkerEnabledValue {
        runApp()
        return
    }
    runSupervisor()
}

The main() function is worth going over. It starts with pulling an environment variable and evaluating its value. If it's enabled, the main application logic in runApp() is invoked. RUN_WORKER_PROCESS is not enabled when the application is started, so the code will first invoke the runSupervisor which first initializes the terminal, then defers a restoreTerminal function. The supervisor then calls the executable and passes our arguments. It sets the environment variable that is used in the main loop. Then there is some logic for wiring up the terminal session.

func runSupervisor() {
    state, err := initTerminal()
    if err != nil {
        log.WithError(err).Error("failed to initialize terminal staet")
        return
    }
    defer restoreTerminal(state)

    // call this binary again in worker mode
    // os.Args[0] current executable
    // os.Args[1:] args passed by user
    cmd := exec.Command(os.Args[0], os.Args[1:]...)

    cmd.Env = append(os.Environ(), fmt.Sprintf(runWorkerProcess+"=1"))

    // Here want to interact with the user's terminal so this wires our current session up properly.
    // this lets the new process inherit the spawning process’s stdout and stderr.
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    runErr := cmd.Run()
    if runErr != nil {
        log.WithError(runErr).Error("worker process exited with an error")
    }
}

The cmd.Run() invokes the application again and in the second pass it calls appRun() and does an early return.

// runApp wraps our application so that it can be ran as a worker process.
func runApp() {
    app := &cli.App{
        Name:     AppName,
        Usage:    AppUsage,
        Author:   AuthorName,
        Version:  "v1.0.0",
        Commands: command.MergeCommandGroups(),
    }
    err := app.Run(os.Args)
    if err != nil {
        log.WithError(err).Error("error starting application")
    }
}

There are two other functions that make this work. The first is the initTerminal(). This does what it says on the tin. A function that gets and returns the terminal state.



// initTerminal sets saves the terminal startup state func initTerminal() (*term.State, error) { // if file descriptor is not terminal, do not retrieve state if !term.IsTerminal(int(os.Stdin.Fd())) { return nil, nil } state, err := term.GetState(int(os.Stdin.Fd())) if err != nil { // Log the error but allow the application to continue. // Restoration will not be possible. log.WithError(err).Error("failed to get terminal state") return nil, err } return state, nil }

The next is restoreTerminal(). This method is deferred so that it restores the terminal state to before the application started running upon exit.

// restoreTerminal sets the terminal back to the state at startup
func restoreTerminal(state *term.State) {
    if state != nil {
        log.Info("restoring terminal state")
        err := term.Restore(int(os.Stdin.Fd()), state)
        if err != nil {
            // this log is a reminder command I added in case something similar happens a long time from
            // when I wrote this. 
            log.WithError(err).Error("unable to restore state try running: stty sane")
        }
    }

}

That is a naive implementation of an Erlang-inspired supervisor tree in Go. It's here as long as that bug in the yarlson/tap codebase exists. I cannot remember which resource to credit for effectively invoking the program a second time within a running Go application. This implementation feels like an anti-pattern, and I don't suspect it's something I will whip out very often, but it did provide a solution to my predicament.

If you want to use the supervisor pattern in your own code, or simply want something better designed than this, there are libraries like suture and go-supervise that implement a better, more robust implementation than what I've done here. If you liked this piece, please consider supporting my work.

Bibliography

Go by Example: Spawning Processes. https://gobyexample.com/spawning-processes. Accessed 11 May 2026.

“Managing Go Processes.” Keploy Blog, 20 Apr. 2024, https://keploy.io/blog/technology/managing-go-processes.

Mezhenskyi, Nikita. “Managing Linux Processes in Go.” Mezhenskyi.Dev, 14 Jan. 2023, https://mezhenskyi.dev/posts/go-linux-processes/.

Os Package - Os - Go Packages. https://pkg.go.dev/os#Exit. Accessed 13 May 2026.

Son, Aaron. Some Useful Patterns for Go’s Os/Exec | DoltHub Blog. 28 Nov. 2022, https://www.dolthub.com/blog/2022-11-28-go-os-exec-patterns/.

“Supervision Trees.” Adopting Erlang, 18 Sept. 2019, https://adoptingerlang.org/docs/development/supervision_trees/.

Suture - Supervisor Trees for Go - iRi. https://jerf.org/iri/post/2930/. Accessed 13 May 2026.

Terminal Mode (MIT/GNU Scheme 12.1). https://www.gnu.org/software/mit-scheme/documentation/stable/mit-scheme-ref/Terminal-Mode.html. Accessed 13 May 2026.

User3248756. "Run Goroutines on Separate Processes (Multiprocessing)." Stack Overflow, 26 Jun. 2017, stackoverflow.com/questions/51091526/. Accessed 11 May 2024.

Zhiyanov, Anton. Native Threading and Multiprocessing in Go. 22 Sept. 2025, https://antonz.org/multi/.

License

TTY Cooking with Supervisors In Go © 2026 by Glass Hound Computing, LLC is licensed under CC BY-NC-SA 4.0. To view a copy of this license, visit https://creativecommons.org/licenses/by-nc-sa/4.0/

The accompanying image Cooked Supervision © 2026 by Glass Hound Computing, LLC is licensed under CC BY-SA 4.0. To view a copy of this license, visit https://creativecommons.org/licenses/by-nc-sa/4.0/