From 369dfcf338ceb294e6264943681df567c70a50fc Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 7 Jun 2026 11:36:57 -0400 Subject: [PATCH] fix(launcher): wait up to 5m for cold first server start; bail on crash First launch on Windows can exceed the old 60s readiness wait because Windows Defender scans the freshly written torch DLLs on first import, so the browser never opened. Poll up to 5 minutes (it opens the instant the port is reachable, so no penalty on the normal path), and abort immediately via a done channel if the server process exits, so a crash no longer waits out the timeout. Co-Authored-By: Claude Opus 4.8 (1M context) --- launcher/browser.go | 57 ++++++++++++++++++++++------------------ launcher/browser_test.go | 38 +++++++++++++++++++++++++++ launcher/main.go | 6 ++++- 3 files changed, 75 insertions(+), 26 deletions(-) create mode 100644 launcher/browser_test.go diff --git a/launcher/browser.go b/launcher/browser.go index 210d765e..ed43884b 100644 --- a/launcher/browser.go +++ b/launcher/browser.go @@ -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 { @@ -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) } diff --git a/launcher/browser_test.go b/launcher/browser_test.go new file mode 100644 index 00000000..3eba4f18 --- /dev/null +++ b/launcher/browser_test.go @@ -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) + } +} diff --git a/launcher/main.go b/launcher/main.go index ff722fca..505a9f04 100644 --- a/launcher/main.go +++ b/launcher/main.go @@ -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. @@ -165,6 +168,7 @@ func launchServer(l layout, noBrowser bool, serverArgs []string) error { }() err := cmd.Wait() + close(serverExited) if shuttingDown.Load() { return nil }