Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 32 additions & 25 deletions launcher/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import (
"time"
)

// How long to wait for the server to start accepting connections before telling
// the user to open it manually. A cold first start on Windows can take minutes
// (Windows Defender scans the freshly written torch DLLs on first import), so
// this is generous; in the normal case the browser opens the moment the port is
// reachable, well before the cap, and `done` aborts the wait if the server dies.
const serverReadyTimeout = 5 * time.Minute

// openBrowser opens url in the user's default browser, per platform.
func openBrowser(url string) error {
switch runtime.GOOS {
Expand All @@ -20,35 +27,35 @@ func openBrowser(url string) error {
}
}

// waitForServer blocks until host:port accepts a TCP connection or timeout elapses.
func waitForServer(host, port string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
// openWhenReady waits until host:port accepts a connection, then opens the
// browser (or just prints the URL when noBrowser). It returns early if `done`
// is closed — i.e. the server process exited before it ever came up — so a
// crashed server doesn't leave us waiting out the whole timeout.
func openWhenReady(host, port string, noBrowser bool, done <-chan struct{}) {
addr := net.JoinHostPort(host, port)
url := fmt.Sprintf("http://%s:%s", host, port)
deadline := time.Now().Add(serverReadyTimeout)

for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
select {
case <-done:
return // server exited before becoming reachable
default:
}
conn, err := net.DialTimeout("tcp", addr, time.Second)
if err == nil {
_ = conn.Close()
return true
if noBrowser {
fmt.Printf("\nPhotoMapAI is running. Open %s in your browser.\n", url)
return
}
fmt.Printf("\nPhotoMapAI is running. Opening %s ...\n", url)
if err := openBrowser(url); err != nil {
fmt.Printf("Could not open a browser automatically. Open %s manually.\n", url)
}
return
}
time.Sleep(250 * time.Millisecond)
}
return false
}

// openWhenReady waits for the server, then opens the browser. Intended to run in
// a goroutine while the server process is supervised in the foreground.
func openWhenReady(host, port string, noBrowser bool) {
if !waitForServer(host, port, 60*time.Second) {
fmt.Println("Server did not become ready in time; open it manually once it starts.")
return
}
url := fmt.Sprintf("http://%s:%s", host, port)
if noBrowser {
fmt.Printf("\nPhotoMapAI is running. Open %s in your browser.\n", url)
return
}
fmt.Printf("\nPhotoMapAI is running. Opening %s ...\n", url)
if err := openBrowser(url); err != nil {
fmt.Printf("Could not open a browser automatically. Open %s manually.\n", url)
time.Sleep(time.Second)
}
fmt.Printf("\nThe server is taking longer than usual to start. Once it's ready, open %s in your browser.\n", url)
}
38 changes: 38 additions & 0 deletions launcher/browser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package main

import (
"net"
"testing"
"time"
)

// When the server exits before becoming reachable, openWhenReady must return
// promptly via the done channel rather than waiting out serverReadyTimeout.
func TestOpenWhenReadyBailsOnServerExit(t *testing.T) {
done := make(chan struct{})
close(done)
start := time.Now()
openWhenReady("127.0.0.1", "59999", true, done) // nothing listening
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Fatalf("did not bail promptly on server exit (took %s)", elapsed)
}
}

// When the port is reachable, openWhenReady detects it quickly (noBrowser=true
// so no real browser is launched).
func TestOpenWhenReadyDetectsReachable(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
_, port, _ := net.SplitHostPort(ln.Addr().String())

done := make(chan struct{})
defer close(done)
start := time.Now()
openWhenReady("127.0.0.1", port, true, done)
if elapsed := time.Since(start); elapsed > 3*time.Second {
t.Fatalf("did not detect a reachable port quickly (took %s)", elapsed)
}
}
6 changes: 5 additions & 1 deletion launcher/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,10 @@ func launchServer(l layout, noBrowser bool, serverArgs []string) error {
if host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
go openWhenReady(host, port, noBrowser)
// Open the browser once the server is reachable; closed when it exits so the
// poller stops immediately if the server crashes instead of waiting it out.
serverExited := make(chan struct{})
go openWhenReady(host, port, noBrowser, serverExited)

// Forward interrupts to the server so Ctrl+C shuts it down cleanly. A shutdown
// we initiated is not an error, so we don't want to show the error/pause path.
Expand All @@ -165,6 +168,7 @@ func launchServer(l layout, noBrowser bool, serverArgs []string) error {
}()

err := cmd.Wait()
close(serverExited)
if shuttingDown.Load() {
return nil
}
Expand Down
Loading