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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +53 -23
M internal/app/run.go +53 -23
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "encoding/hex" | |
| 8 | 8 | "encoding/json" | |
| 9 | 9 | "fmt" | |
| 10 | + | "net" | |
| 10 | 11 | "net/http" | |
| 11 | 12 | "os" | |
| 12 | 13 | "os/signal" | |
| @@ -181,14 +182,11 @@ func Run(ctx context.Context, configPath string) error { | |||
| 181 | 182 | } | |
| 182 | 183 | defer svc.eventBus.Stop() | |
| 183 | 184 | ||
| 184 | - | // Sync and auto-start containers | |
| 185 | - | syncAndAutoStart(ctx, svc, log) | |
| 186 | - | ||
| 187 | 185 | // Create HTTP handlers | |
| 188 | 186 | registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log) | |
| 189 | 187 | ||
| 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) | |
| 192 | 190 | } | |
| 193 | 191 | ||
| 194 | 192 | // initConfig loads configuration from file. | |
| @@ -1063,7 +1061,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1063 | 1061 | // - SIGUSR2: Triggers manual deploy for a specific route | |
| 1064 | 1062 | // The deferred signal.Stop calls ensure signal handlers are properly | |
| 1065 | 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 | 1065 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 1068 | 1066 | defer cancel() | |
| 1069 | 1067 | ||
| @@ -1081,14 +1079,30 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h | |||
| 1081 | 1079 | ||
| 1082 | 1080 | errChan := make(chan error, 2) | |
| 1083 | 1081 | ||
| 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 | + | } | |
| 1086 | 1097 | ||
| 1087 | 1098 | log.Info(). | |
| 1088 | 1099 | Int("proxy_port", cfg.Server.Port). | |
| 1089 | 1100 | Int("registry_port", cfg.Server.RegistryPort). | |
| 1090 | 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 | 1106 | for { | |
| 1093 | 1107 | select { | |
| 1094 | 1108 | case err := <-errChan: | |
| @@ -1129,23 +1143,39 @@ shutdown: | |||
| 1129 | 1143 | return nil | |
| 1130 | 1144 | } | |
| 1131 | 1145 | ||
| 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 | + | } | |
| 1135 | 1165 | ||
| 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 | |
| 1145 | 1172 | ||
| 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 | |
| 1149 | 1179 | } | |
| 1150 | 1180 | ||
| 1151 | 1181 | // 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 handlers | 185 | // 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 shutdown | 188 | + | // 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 route | 1061 | // - SIGUSR2: Triggers manual deploy for a specific route | ||
| 1064 | // The deferred signal.Stop calls ensure signal handlers are properly | 1062 | // 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 nil | 1143 | 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 request | 1170 | + | } |
| 1141 | - | WriteTimeout: 5 * time.Minute, // Timeout for writing response | 1171 | + | 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. | ||