Endpoint Roulette: Spin the Endpoint, Crash the App (Go Edition)

Abenezer Belachew

Abenezer Belachew · November 13, 2024

5 min read

Imagine you want the ability to remotely kill your Go app with a simple HTTP request. Perhaps you don't have direct access to the server, or you'd rather avoid SSH-ing in just to stop the process. Instead, you want to visit a specific endpoint and have the app terminate itself. How would you make that happen?

Take a few seconds to think about it. How would you implement an endpoint that crashes the app when visited?


One way to achieve this is by triggering a panic in the app. However, you can't simply call panic in a standard handler. Go's HTTP server is designed to recover from panics within handlers to prevent the entire app from crashing. It assumes that the effect of any panic is limited to the goroutine that is handling the request. To crash the entire app, you need to panic from a separate goroutine initiated by the handler.

Panic

Here's a basic Go server that triggers a crash when visiting the /kys endpoint.

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", root)
	http.HandleFunc("/kys", kys)

	if err := http.ListenAndServe(":1234", nil); err != nil {
		fmt.Println("server error:", err)
	}
}

func root(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "boring endpoint 🥱️")
}

func kys(w http.ResponseWriter, r *http.Request) {
	go panic("yeah, now i'm panicking.")
	fmt.Fprintln(w, "server crashed 💥️")
}

Test it

$ curl localhost:1234
boring endpoint 🥱️

$ curl localhost:1234/kys
server crashed 💥️

on the server logs, you'll see the panic message.

$ go run test2.go
panic: something went wrong.

goroutine 7 [running]:
main.kys.gowrap1()
        /home/ab/go/random/panicking-goroutine/test2.go:22 +0x25
created by main.kys in goroutine 20
        /home/ab/go/random/panicking-goroutine/test2.go:22 +0x4d
exit status 2

If your sole objective is to crash the app, using a goroutine with a panic call will do the job.

func kys(w http.ResponseWriter, r *http.Request) {
	go panic("yeah, now i'm panicking.")
	w.(http.Flusher).Flush() // Ensure immediate output
	fmt.Fprintln(w, "server crashed 💥️😵️")
}

os.Exit

Another way to crash the app is by calling os.Exit(1). This will immediately terminate the app, regardless of any running goroutines. However, it's not recommended to use os.Exit in a web server because it doesn't give the server a chance to clean up resources or finish handling requests.

func kys(w http.ResponseWriter, r *http.Request) {
	go os.Exit(1)
	fmt.Fprintln(w, "server crashed 💥️😵️")
}

It also doesn't really give you a chance to respond to the client before the app crashes. But it does the job.

But there's nothing exciting about these approaches. They're predictable—you know exactly what will happen. Visit the /kys endpoint, and the app crashes.

It would be a lot more fun if there was an element of chance involved.

Endpoint Roulette

To spice things up, let's make it a game of chance! We can create an endpoint with a probability of crashing the app (let's say, a 30% chance). Then, each time you visit the endpoint, you can see if you're “lucky” enough to crash the app.

package main

import (
	"fmt"
	"math/rand"
	"net/http"
	"strconv"
)

func main() {
	http.HandleFunc("/", root)
	http.HandleFunc("/kys", kys)

	if err := http.ListenAndServe(":1234", nil); err != nil {
		fmt.Println("server error:", err)
	}
}

func root(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "boring endpoint 🥱️")
}

func kys(w http.ResponseWriter, r *http.Request) {
	done := make(chan struct{}) // Channel to signal completion

	go func() {
		defer close(done) // Close channel when goroutine finishes
		if rand.Intn(10) < 3 { // 30% chance to crash
			message := "server crashed 💥"
			w.Header().Set("Content-Length", strconv.Itoa(len(message)+1))
			fmt.Fprintln(w, message)
			w.(http.Flusher).Flush() // Ensure immediate output
			panic(message)
		} else {
			fmt.Fprintln(w, "🎊️ survived")
		}
	}()

	<-done // Wait for the goroutine to finish
}

Why run it in a goroutine?

If you don't run it in a separate goroutine, the panic will only kill the request that's running on that goroutine. Each request is handled in a separate goroutine. So if you panic on a handler, it will only kill that request. But if you run it in another goroutine, and letting it panic without recovery, the entire app will crash.

Why the channel?

The channel is used to wait for the goroutine to finish. Without it, the app might exit before the goroutine completes, since the main goroutine exits when ListenAndServe returns an error. By using a channel, we synchronize the goroutine's execution with the main flow.

Why the flush?

The Flush call ensures that the response is sent immediately. Without Flush, the response might be buffered and only sent once the buffer is full, which we don't want. We need the client to get the response right away, so they know if the app crashed or survived.

Test it

$ curl localhost:1234
boring endpoint 🥱

$ curl localhost:1234/kys
🎊️ survived

$ curl localhost:1234/kys
🎊️ survived

$ curl localhost:1234/kys
🎊️ survived

$ curl localhost:1234/kys
server crashed 💥

$ curl localhost:1234/kys
curl: (7) Failed to connect to localhost port 1234 after 0 ms: Connection refused

The logs will show the panic message, similar to the previous example.

🍰️