From 8ddccc12853f7b43d211383c2ab9ec225640286e Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Sat, 11 Apr 2026 13:19:11 +0100 Subject: [PATCH] gui: drop freePort helper, use libhttp port binding for the RC server Bind the RC server to localhost:0 and read the bound URL back via a new rcserver.Server.URLs() accessor instead of pre-allocating a port in cmd/gui. This removes the small TOCTOU race window between freePort() closing its listener and rcserver claiming the same port. --- cmd/gui/gui.go | 23 ++++------------------- cmd/gui/gui_test.go | 7 ------- fs/rc/rcserver/rcserver.go | 6 ++++++ 3 files changed, 10 insertions(+), 26 deletions(-) diff --git a/cmd/gui/gui.go b/cmd/gui/gui.go index 434ab4aa5..eb082e26f 100644 --- a/cmd/gui/gui.go +++ b/cmd/gui/gui.go @@ -8,7 +8,6 @@ import ( "flag" "fmt" iofs "io/fs" - "net" "net/http" "net/url" "os" @@ -134,11 +133,7 @@ Use --no-auth to disable authentication entirely: if command.Flags().Changed("api-addr") { opt.HTTP.ListenAddr = apiAddr } else { - port, err := freePort() - if err != nil { - return fmt.Errorf("failed to find a free port for RC: %w", err) - } - opt.HTTP.ListenAddr = []string{fmt.Sprintf("localhost:%d", port)} + opt.HTTP.ListenAddr = []string{"localhost:0"} } // CORS: allow the GUI origin to make cross-port API requests. @@ -181,9 +176,9 @@ Use --no-auth to disable authentication entirely: return fmt.Errorf("failed to start RC server: %w", err) } - // Build the RC URL from the address we configured (rcserver.Server - // does not expose URLs, and we know the address we passed in). - rcURL := "http://" + opt.HTTP.ListenAddr[0] + "/" + // Read the bound RC URL back from rcserver, in case we asked + // libhttp to pick a free port (localhost:0). + rcURL := rcServer.URLs()[0] // Mount the GUI handler and start serving spaHandler, err := guiHandler(srcFS) @@ -219,16 +214,6 @@ Use --no-auth to disable authentication entirely: }, } -// freePort asks the OS for a free TCP port on localhost. -func freePort() (int, error) { - l, err := net.Listen("tcp", "localhost:0") - if err != nil { - return 0, err - } - defer func() { _ = l.Close() }() - return l.Addr().(*net.TCPAddr).Port, nil -} - // originFromURL extracts the origin (scheme://host) from a URL string, // stripping any path or trailing slash. func originFromURL(rawURL string) string { diff --git a/cmd/gui/gui_test.go b/cmd/gui/gui_test.go index 0a9978e46..f6f487d48 100644 --- a/cmd/gui/gui_test.go +++ b/cmd/gui/gui_test.go @@ -96,13 +96,6 @@ func TestOriginFromURL(t *testing.T) { } } -func TestFreePort(t *testing.T) { - port, err := freePort() - assert.NoError(t, err) - assert.Greater(t, port, 0) - assert.Less(t, port, 65536) -} - // newTestHandler returns a guiHandler backed by the embedded GUI // bundle, or skips the test if it is not present (i.e. `make fetch-gui` // has not been run). diff --git a/fs/rc/rcserver/rcserver.go b/fs/rc/rcserver/rcserver.go index 7b6622c43..745a66c07 100644 --- a/fs/rc/rcserver/rcserver.go +++ b/fs/rc/rcserver/rcserver.go @@ -409,6 +409,12 @@ func (s *Server) handleGet(w http.ResponseWriter, r *http.Request, path string) http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) } +// URLs returns the URLs the server is listening on. Only valid after +// Serve has been called (which Start does internally before returning). +func (s *Server) URLs() []string { + return s.server.URLs() +} + // Wait blocks while the server is serving requests func (s *Server) Wait() { s.server.Wait()