Skip to content

Commit 15add88

Browse files
lsteinclaude
andauthored
fix(launcher): wait up to 5m for cold first server start; bail on crash (#305)
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) <noreply@anthropic.com>
1 parent 055c6da commit 15add88

3 files changed

Lines changed: 75 additions & 26 deletions

File tree

launcher/browser.go

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ import (
88
"time"
99
)
1010

11+
// How long to wait for the server to start accepting connections before telling
12+
// the user to open it manually. A cold first start on Windows can take minutes
13+
// (Windows Defender scans the freshly written torch DLLs on first import), so
14+
// this is generous; in the normal case the browser opens the moment the port is
15+
// reachable, well before the cap, and `done` aborts the wait if the server dies.
16+
const serverReadyTimeout = 5 * time.Minute
17+
1118
// openBrowser opens url in the user's default browser, per platform.
1219
func openBrowser(url string) error {
1320
switch runtime.GOOS {
@@ -20,35 +27,35 @@ func openBrowser(url string) error {
2027
}
2128
}
2229

23-
// waitForServer blocks until host:port accepts a TCP connection or timeout elapses.
24-
func waitForServer(host, port string, timeout time.Duration) bool {
25-
deadline := time.Now().Add(timeout)
30+
// openWhenReady waits until host:port accepts a connection, then opens the
31+
// browser (or just prints the URL when noBrowser). It returns early if `done`
32+
// is closed — i.e. the server process exited before it ever came up — so a
33+
// crashed server doesn't leave us waiting out the whole timeout.
34+
func openWhenReady(host, port string, noBrowser bool, done <-chan struct{}) {
2635
addr := net.JoinHostPort(host, port)
36+
url := fmt.Sprintf("http://%s:%s", host, port)
37+
deadline := time.Now().Add(serverReadyTimeout)
38+
2739
for time.Now().Before(deadline) {
28-
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
40+
select {
41+
case <-done:
42+
return // server exited before becoming reachable
43+
default:
44+
}
45+
conn, err := net.DialTimeout("tcp", addr, time.Second)
2946
if err == nil {
3047
_ = conn.Close()
31-
return true
48+
if noBrowser {
49+
fmt.Printf("\nPhotoMapAI is running. Open %s in your browser.\n", url)
50+
return
51+
}
52+
fmt.Printf("\nPhotoMapAI is running. Opening %s ...\n", url)
53+
if err := openBrowser(url); err != nil {
54+
fmt.Printf("Could not open a browser automatically. Open %s manually.\n", url)
55+
}
56+
return
3257
}
33-
time.Sleep(250 * time.Millisecond)
34-
}
35-
return false
36-
}
37-
38-
// openWhenReady waits for the server, then opens the browser. Intended to run in
39-
// a goroutine while the server process is supervised in the foreground.
40-
func openWhenReady(host, port string, noBrowser bool) {
41-
if !waitForServer(host, port, 60*time.Second) {
42-
fmt.Println("Server did not become ready in time; open it manually once it starts.")
43-
return
44-
}
45-
url := fmt.Sprintf("http://%s:%s", host, port)
46-
if noBrowser {
47-
fmt.Printf("\nPhotoMapAI is running. Open %s in your browser.\n", url)
48-
return
49-
}
50-
fmt.Printf("\nPhotoMapAI is running. Opening %s ...\n", url)
51-
if err := openBrowser(url); err != nil {
52-
fmt.Printf("Could not open a browser automatically. Open %s manually.\n", url)
58+
time.Sleep(time.Second)
5359
}
60+
fmt.Printf("\nThe server is taking longer than usual to start. Once it's ready, open %s in your browser.\n", url)
5461
}

launcher/browser_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package main
2+
3+
import (
4+
"net"
5+
"testing"
6+
"time"
7+
)
8+
9+
// When the server exits before becoming reachable, openWhenReady must return
10+
// promptly via the done channel rather than waiting out serverReadyTimeout.
11+
func TestOpenWhenReadyBailsOnServerExit(t *testing.T) {
12+
done := make(chan struct{})
13+
close(done)
14+
start := time.Now()
15+
openWhenReady("127.0.0.1", "59999", true, done) // nothing listening
16+
if elapsed := time.Since(start); elapsed > 2*time.Second {
17+
t.Fatalf("did not bail promptly on server exit (took %s)", elapsed)
18+
}
19+
}
20+
21+
// When the port is reachable, openWhenReady detects it quickly (noBrowser=true
22+
// so no real browser is launched).
23+
func TestOpenWhenReadyDetectsReachable(t *testing.T) {
24+
ln, err := net.Listen("tcp", "127.0.0.1:0")
25+
if err != nil {
26+
t.Fatal(err)
27+
}
28+
defer ln.Close()
29+
_, port, _ := net.SplitHostPort(ln.Addr().String())
30+
31+
done := make(chan struct{})
32+
defer close(done)
33+
start := time.Now()
34+
openWhenReady("127.0.0.1", port, true, done)
35+
if elapsed := time.Since(start); elapsed > 3*time.Second {
36+
t.Fatalf("did not detect a reachable port quickly (took %s)", elapsed)
37+
}
38+
}

launcher/main.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,10 @@ func launchServer(l layout, noBrowser bool, serverArgs []string) error {
151151
if host == "0.0.0.0" || host == "::" {
152152
host = "127.0.0.1"
153153
}
154-
go openWhenReady(host, port, noBrowser)
154+
// Open the browser once the server is reachable; closed when it exits so the
155+
// poller stops immediately if the server crashes instead of waiting it out.
156+
serverExited := make(chan struct{})
157+
go openWhenReady(host, port, noBrowser, serverExited)
155158

156159
// Forward interrupts to the server so Ctrl+C shuts it down cleanly. A shutdown
157160
// 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 {
165168
}()
166169

167170
err := cmd.Wait()
171+
close(serverExited)
168172
if shuttingDown.Load() {
169173
return nil
170174
}

0 commit comments

Comments
 (0)