detached ebd5aea

fix(startup): start registry listener before auto-start

syncAndAutoStart ran before runServers, so the registry port wasn't
bound yet when auto-start tried to pull images. Split startServer
into Listen+Serve so we can wait for the port to be bound before
triggering auto-start.

Fixes the race condition half of #54.
commit
ebd5aea596dd4dd1ac9f308c16d6c8746fb5cf56
author
brice · · 2026-02-06 05:10 UTC
parent
67442d7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +53 -23
M internal/app/run.go +53 -23
@@ -7,6 +7,7 @@ import (
77 "encoding/hex"
88 "encoding/json"
99 "fmt"
10+ "net"
1011 "net/http"
1112 "os"
1213 "os/signal"
@@ -181,14 +182,11 @@ func Run(ctx context.Context, configPath string) error {
181182 }
182183 defer svc.eventBus.Stop()
183184
184- // Sync and auto-start containers
185- syncAndAutoStart(ctx, svc, log)
186-
187185 // Create HTTP handlers
188186 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
189187
190- // Start servers and wait for shutdown
191- return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, log)
188+ // Start servers, wait for listeners to bind, then sync/auto-start containers.
189+ return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)
192190 }
193191
194192 // initConfig loads configuration from file.
@@ -1063,7 +1061,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
10631061 // - SIGUSR2: Triggers manual deploy for a specific route
10641062 // The deferred signal.Stop calls ensure signal handlers are properly
10651063 // cleaned up before program exit, preventing signal handler leaks.
1066-func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error {
1064+func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, log zerowrap.Logger) error {
10671065 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
10681066 defer cancel()
10691067
@@ -1081,14 +1079,30 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
10811079
10821080 errChan := make(chan error, 2)
10831081
1084- go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1085- go startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1082+ registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1083+ proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1084+
1085+ // Wait for both servers to bind their ports before auto-starting containers.
1086+ // This prevents the race where auto-start pulls from the registry before it's listening.
1087+ select {
1088+ case <-registryReady:
1089+ case err := <-errChan:
1090+ return err
1091+ }
1092+ select {
1093+ case <-proxyReady:
1094+ case err := <-errChan:
1095+ return err
1096+ }
10861097
10871098 log.Info().
10881099 Int("proxy_port", cfg.Server.Port).
10891100 Int("registry_port", cfg.Server.RegistryPort).
10901101 Msg("Gordon is running")
10911102
1103+ // Auto-start after servers are listening (registry port is now bound).
1104+ syncAndAutoStart(ctx, svc, log)
1105+
10921106 for {
10931107 select {
10941108 case err := <-errChan:
@@ -1129,23 +1143,39 @@ shutdown:
11291143 return nil
11301144 }
11311145
1132-// startServer starts an HTTP server.
1133-func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) {
1134- log.Info().Str("address", addr).Msgf("%s server starting", name)
1146+// startServer starts an HTTP server, returning a channel that closes once the
1147+// listening socket is bound. This lets callers wait for the port to be ready
1148+// before taking actions that depend on it (e.g. auto-start pulling from the
1149+// local registry).
1150+func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {
1151+ ready := make(chan struct{})
1152+
1153+ go func() {
1154+ log.Info().Str("address", addr).Msgf("%s server starting", name)
1155+
1156+ server := &http.Server{
1157+ Addr: addr,
1158+ Handler: handler,
1159+ ReadHeaderTimeout: 10 * time.Second,
1160+ ReadTimeout: 5 * time.Minute,
1161+ WriteTimeout: 5 * time.Minute,
1162+ IdleTimeout: 120 * time.Second,
1163+ MaxHeaderBytes: 1 << 20,
1164+ }
11351165
1136- server := &http.Server{
1137- Addr: addr,
1138- Handler: handler,
1139- ReadHeaderTimeout: 10 * time.Second,
1140- ReadTimeout: 5 * time.Minute, // Timeout for reading entire request
1141- WriteTimeout: 5 * time.Minute, // Timeout for writing response
1142- IdleTimeout: 120 * time.Second, // Timeout for idle keep-alive connections
1143- MaxHeaderBytes: 1 << 20, // 1MB max header size
1144- }
1166+ ln, err := net.Listen("tcp", addr)
1167+ if err != nil {
1168+ errChan <- fmt.Errorf("%s server error: %w", name, err)
1169+ return
1170+ }
1171+ close(ready) // signal: port is bound and accepting connections
11451172
1146- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
1147- errChan <- fmt.Errorf("%s server error: %w", name, err)
1148- }
1173+ if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
1174+ errChan <- fmt.Errorf("%s server error: %w", name, err)
1175+ }
1176+ }()
1177+
1178+ return ready
11491179 }
11501180
11511181 // SendReloadSignal sends SIGUSR1 to the running Gordon process.
@@ -7,6 +7,7 @@ import (
7 "encoding/hex"7 "encoding/hex"
8 "encoding/json"8 "encoding/json"
9 "fmt"9 "fmt"
10+ "net"
10 "net/http"11 "net/http"
11 "os"12 "os"
12 "os/signal"13 "os/signal"
@@ -181,14 +182,11 @@ func Run(ctx context.Context, configPath string) error {
181 }182 }
182 defer svc.eventBus.Stop()183 defer svc.eventBus.Stop()
183 184
184- // Sync and auto-start containers
185- syncAndAutoStart(ctx, svc, log)
186-
187 // Create HTTP handlers185 // Create HTTP handlers
188 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)186 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
189 187
190- // Start servers and wait for shutdown188+ // Start servers, wait for listeners to bind, then sync/auto-start containers.
191- return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, log)189+ return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)
192 }190 }
193 191
194 // initConfig loads configuration from file.192 // initConfig loads configuration from file.
@@ -1063,7 +1061,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1063 // - SIGUSR2: Triggers manual deploy for a specific route1061 // - SIGUSR2: Triggers manual deploy for a specific route
1064 // The deferred signal.Stop calls ensure signal handlers are properly1062 // The deferred signal.Stop calls ensure signal handlers are properly
1065 // cleaned up before program exit, preventing signal handler leaks.1063 // cleaned up before program exit, preventing signal handler leaks.
1066-func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error {1064+func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, log zerowrap.Logger) error {
1067 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)1065 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
1068 defer cancel()1066 defer cancel()
1069 1067
@@ -1081,14 +1079,30 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
1081 1079
1082 errChan := make(chan error, 2)1080 errChan := make(chan error, 2)
1083 1081
1084- go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)1082+ registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1085- go startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)1083+ proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1084+
1085+ // Wait for both servers to bind their ports before auto-starting containers.
1086+ // This prevents the race where auto-start pulls from the registry before it's listening.
1087+ select {
1088+ case <-registryReady:
1089+ case err := <-errChan:
1090+ return err
1091+ }
1092+ select {
1093+ case <-proxyReady:
1094+ case err := <-errChan:
1095+ return err
1096+ }
1086 1097
1087 log.Info().1098 log.Info().
1088 Int("proxy_port", cfg.Server.Port).1099 Int("proxy_port", cfg.Server.Port).
1089 Int("registry_port", cfg.Server.RegistryPort).1100 Int("registry_port", cfg.Server.RegistryPort).
1090 Msg("Gordon is running")1101 Msg("Gordon is running")
1091 1102
1103+ // Auto-start after servers are listening (registry port is now bound).
1104+ syncAndAutoStart(ctx, svc, log)
1105+
1092 for {1106 for {
1093 select {1107 select {
1094 case err := <-errChan:1108 case err := <-errChan:
@@ -1129,23 +1143,39 @@ shutdown:
1129 return nil1143 return nil
1130 }1144 }
1131 1145
1132-// startServer starts an HTTP server.1146+// startServer starts an HTTP server, returning a channel that closes once the
1133-func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) {1147+// listening socket is bound. This lets callers wait for the port to be ready
1134- log.Info().Str("address", addr).Msgf("%s server starting", name)1148+// before taking actions that depend on it (e.g. auto-start pulling from the
1149+// local registry).
1150+func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {
1151+ ready := make(chan struct{})
1152+
1153+ go func() {
1154+ log.Info().Str("address", addr).Msgf("%s server starting", name)
1155+
1156+ server := &http.Server{
1157+ Addr: addr,
1158+ Handler: handler,
1159+ ReadHeaderTimeout: 10 * time.Second,
1160+ ReadTimeout: 5 * time.Minute,
1161+ WriteTimeout: 5 * time.Minute,
1162+ IdleTimeout: 120 * time.Second,
1163+ MaxHeaderBytes: 1 << 20,
1164+ }
1135 1165
1136- server := &http.Server{1166+ ln, err := net.Listen("tcp", addr)
1137- Addr: addr,1167+ if err != nil {
1138- Handler: handler,1168+ errChan <- fmt.Errorf("%s server error: %w", name, err)
1139- ReadHeaderTimeout: 10 * time.Second,1169+ return
1140- ReadTimeout: 5 * time.Minute, // Timeout for reading entire request1170+ }
1141- WriteTimeout: 5 * time.Minute, // Timeout for writing response1171+ close(ready) // signal: port is bound and accepting connections
1142- IdleTimeout: 120 * time.Second, // Timeout for idle keep-alive connections
1143- MaxHeaderBytes: 1 << 20, // 1MB max header size
1144- }
1145 1172
1146- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {1173+ if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
1147- errChan <- fmt.Errorf("%s server error: %w", name, err)1174+ errChan <- fmt.Errorf("%s server error: %w", name, err)
1148- }1175+ }
1176+ }()
1177+
1178+ return ready
1149 }1179 }
1150 1180
1151 // SendReloadSignal sends SIGUSR1 to the running Gordon process.1181 // SendReloadSignal sends SIGUSR1 to the running Gordon process.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close