webui: improve the browser opening sequence (#1537)

sudoforge created

This change refactors the browser opening sequence, adding a healthcheck
(with exponential backoff) that ensures the http server is running
before opening the browser. This is done in a simple goroutine to allow
the main thread to continue (and actually attempt to start the server).

SIGQUIT was added as a supported handler, as this signal is used by
applications that they are voluntarily quitting, which we do if the
maximum number of attempts has been reached without successfully
determining that the http server is up.

Additional logs are emitted to provide useful context in the event
of an action (either opening the browser or failing to reach the http
server). This will help end users identify what is happening under the
hood, and determine if there are issues, which will lead to better error
reports and cooperative debugging.

Refs: #1536
Change-Id: I2e2a379ed20a6b3c6d95a209b915f71d56408e1a

Change summary

commands/webui.go | 59 +++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 50 insertions(+), 9 deletions(-)

Detailed changes

commands/webui.go 🔗

@@ -137,11 +137,11 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 	quit := make(chan os.Signal, 1)
 
 	// register as handler of the interrupt signal to trigger the teardown
-	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
+	signal.Notify(quit, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM, os.Interrupt)
 
 	go func() {
 		<-quit
-		env.Out.Println("WebUI is shutting down...")
+		env.Out.Println("shutting down...")
 
 		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 		defer cancel()
@@ -163,7 +163,7 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 	env.Out.Printf("Web UI: %s\n", webUiAddr)
 	env.Out.Printf("Graphql API: http://%s/graphql\n", addr)
 	env.Out.Printf("Graphql Playground: http://%s/playground\n", addr)
-	env.Out.Println("Press Ctrl+c to quit")
+	env.Out.Printf("[ Press Ctrl+c to quit ]\n\n")
 
 	configOpen, err := env.Repo.AnyConfig().ReadBool(webUIOpenConfigKey)
 	if errors.Is(err, repository.ErrNoConfigEntry) {
@@ -176,10 +176,28 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 	shouldOpen := (configOpen && !opts.noOpen) || opts.open
 
 	if shouldOpen {
-		err = open.Run(toOpen)
-		if err != nil {
-			env.Out.Println(err)
-		}
+		go func() {
+			maxAttempts := 3
+			if isUp(toOpen, maxAttempts, 3*time.Second) {
+				err = open.Run(toOpen)
+				if err != nil {
+					env.Out.Println(err)
+					return
+				}
+
+				env.Out.Printf("opened your default browser to url: %s\n", toOpen)
+				return
+			} else {
+				env.Out.Printf(
+					"uh oh! it appears that the http server hasn't started.\n"+
+						"we failed to reach %s after %d attempts, exiting now.\n",
+					toOpen,
+					maxAttempts,
+				)
+				quit <- syscall.SIGQUIT
+				return
+			}
+		}()
 	}
 
 	err = srv.ListenAndServe()
@@ -188,7 +206,30 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 	}
 
 	<-done
-
-	env.Out.Println("WebUI stopped")
 	return nil
 }
+
+func isUp(url string, maxRetries int, initialDelay time.Duration) bool {
+	client := &http.Client{
+		Timeout: 5 * time.Second,
+	}
+
+	delay := initialDelay
+
+	for attempt := 1; attempt <= maxRetries; attempt++ {
+		resp, err := client.Head(url)
+		if err == nil {
+			resp.Body.Close()
+			if resp.StatusCode >= 200 && resp.StatusCode < 400 {
+				return true
+			}
+		}
+
+		if attempt < maxRetries {
+			time.Sleep(delay)
+			delay *= 2
+		}
+	}
+
+	return false
+}