Catching Multiple SIGINTs in Go - Confirming if You Really Want to Quit

3 minute read Published:

Many times, you find yourself in a situation where you have to wait for a long task to complete, maybe little to no output available, and you end up hitting that Ctrl-C, pushing a SIGINT into the CLI software.

And it quits.

Without outputting whatever you needed or even printing a little progress. And it’s a little frustrating, even though it’s probably working as expected, taking what SIGINT does in consideration.

So, how do we introduce a classic “do you really want to exit?” confirmation in a Go CLI software?

The code for the program above can be seen here:

type Thing struct {
	sync.Mutex

	Index  int
	Length int
}

func (t *Thing) DoTask(i int) {
	t.Lock()
	t.Index = i
	fmt.Printf("Doing thing for index #%d\n", t.Index+1)
	time.Sleep(1 * time.Second)
	t.Unlock()
}

var t Thing

func main() {
	fmt.Println("Hello gophers!")

	t.Length = 10
	for i := 0; i < t.Length; i++ {
		t.DoTask(i)
	}
}

We have a Thing struct which inherits the sync.Mutex type, allowing it to have a simple locking/unlocking mechanism. The DoTask method simply locks the struct, sets the current index, sleeps for a while emulating some hard work, then unlocks the struct. Nothing too complicated.

Catching a SIGINT in a Go program involves creating a (buffered) channel of type os.Signal, then using signal.Notify(c chan<- os.Signal, sig ...os.Signal) in order to relay the specified signals - in our case, os.Interrupt - to the channel we created earlier.

func init() {
	c := make(chan os.Signal, 1)
	signal.Notify(c, os.Interrupt)
	go func() {
		for range c {
			fmt.Println("Caught SIGINT")
		}
	}()
}

func main() {
	time.Sleep(30 * time.Second)
}

So how do we make it interactive? How do we tell our worker to pause or stop working on a SIGINT? Well, it’s pretty easy now, noting that our Thing struct already has the locking and unlocking mechanism.

We only have to (ab)use the mutex and lock when catching a signal.

func init() {
	c := make(chan os.Signal, 1)
	signal.Notify(c, os.Interrupt)
	go func() {
		for {
			select {
			case <-c:
				t.Lock()

				fmt.Printf("\n%d/%d tasks, pausing...", t.Index, t.Length)
				fmt.Printf("\nReally quit? [y/n] > ")

				var choice string
				fmt.Fscanf(os.Stdout, "%s", &choice)
				if choice == "y" {
					fmt.Println("Cleaning up...")
					os.Exit(0) // definitely not the best way to exit
				}

				t.Unlock()
			}
		}
	}()
}

What if we don’t need to get user input, but only exit (gracefully)? Let’s suppose we want to gracefully stop a running worker on a SIGINT, but somehow a goroutine blocks it, and no matter how hard or how many times we send a SIGINT, it will just stand there, doing nothing?

We could count the received signals, and force a not-so-graceful exit.

func init() {
	var count int
	c := make(chan os.Signal, 2)
	signal.Notify(c, os.Interrupt)
	go func() {
		for {
			select {
			case <-c:
				count++
				if count == 2 {
					fmt.Println("Forcefully exiting")
					os.Exit(1)
				}
				fmt.Println("Doing something with a signal")
				fmt.Println("Press Ctrl-C one more time to exit")
			}
		}
	}()
}

func main() {
	time.Sleep(30 * time.Second)
}

So there it is. This is merely an example of how one could catch & use an OS signal. The pretty printing and cleaning up remains an exercise for the reader.