feat(ci): add ERMITE_CI_POLL_TIMEOUT to shorten the FetchJob long poll
A reverse proxy that cuts a response without headers sooner than the 50 s poll (Gordon: ResponseHeaderTimeout 30 s) turns every idle poll into a 503, which the runner logs as "unexpected EOF" and answers with a growing backoff. The variable sets how long FetchJob is held, from 1s to 55s (the runner waits 60 s). Unset keeps the 50 s of the contract.
- commit
- e30ca52c5d83306306d65f5840e1684e85d8eae2
- author
- brice · · 2026-10-13 00:43 UTC
- parent
- eaf4295
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +60 -0
M README.md +1 -0
| @@ -29,6 +29,7 @@ ermite serve | |||
| 29 | 29 | | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges | | |
| 30 | 30 | | `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names | | |
| 31 | 31 | | `ERMITE_CI_LOG_RETENTION` | `90d` | how long CI runs, jobs, checks and logs are kept: `Nd` (days) or a Go duration such as `720h`, at least one hour | | |
| 32 | + | | `ERMITE_CI_POLL_TIMEOUT` | `50s` | how long the server holds a runner's `FetchJob` call when no job is queued, from `1s` to `55s`; set it below the response timeout of a reverse proxy in front of ermite (Gordon cuts a response that has no headers after 30 s, so use `25s` behind it) | | |
| 32 | 33 | ||
| 33 | 34 | `ermite version` prints the version (and the server's when a server URL is configured, see Remote CLI). | |
| 34 | 35 | ||
| @@ -29,6 +29,7 @@ ermite serve | |||||
| 29 | | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges | | 29 | | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges | | ||
| 30 | | `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names | | 30 | | `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names | | ||
| 31 | | `ERMITE_CI_LOG_RETENTION` | `90d` | how long CI runs, jobs, checks and logs are kept: `Nd` (days) or a Go duration such as `720h`, at least one hour | | 31 | | `ERMITE_CI_LOG_RETENTION` | `90d` | how long CI runs, jobs, checks and logs are kept: `Nd` (days) or a Go duration such as `720h`, at least one hour | | ||
| 32 | + | | `ERMITE_CI_POLL_TIMEOUT` | `50s` | how long the server holds a runner's `FetchJob` call when no job is queued, from `1s` to `55s`; set it below the response timeout of a reverse proxy in front of ermite (Gordon cuts a response that has no headers after 30 s, so use `25s` behind it) | | |||
| 32 | 33 | ||||
| 33 | `ermite version` prints the version (and the server's when a server URL is configured, see Remote CLI). | 34 | `ermite version` prints the version (and the server's when a server URL is configured, see Remote CLI). | ||
| 34 | 35 | ||||
M internal/app/forge.go +1 -0
| @@ -199,6 +199,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 199 | 199 | Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader, | |
| 200 | 200 | Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth, | |
| 201 | 201 | Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention, | |
| 202 | + | PollTimeout: cfg.CIPollTimeout, | |
| 202 | 203 | } | |
| 203 | 204 | if opts.ci != nil { | |
| 204 | 205 | opts.ci(&ciCfg) | |
| @@ -199,6 +199,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 199 | Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader, | 199 | Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader, | ||
| 200 | Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth, | 200 | Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth, | ||
| 201 | Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention, | 201 | Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention, | ||
| 202 | + | PollTimeout: cfg.CIPollTimeout, | |||
| 202 | } | 203 | } | ||
| 203 | if opts.ci != nil { | 204 | if opts.ci != nil { | ||
| 204 | opts.ci(&ciCfg) | 205 | opts.ci(&ciCfg) | ||
M internal/config/config.go +26 -0
| @@ -39,6 +39,9 @@ const ( | |||
| 39 | 39 | EnvOwner = "ERMITE_OWNER" | |
| 40 | 40 | // EnvCILogRetention is how long CI runs, jobs, checks and logs are kept. | |
| 41 | 41 | EnvCILogRetention = "ERMITE_CI_LOG_RETENTION" | |
| 42 | + | // EnvCIPollTimeout is how long FetchJob holds a runner's call. Lower it | |
| 43 | + | // when a reverse proxy cuts idle responses sooner than the default 50 s. | |
| 44 | + | EnvCIPollTimeout = "ERMITE_CI_POLL_TIMEOUT" | |
| 42 | 45 | ) | |
| 43 | 46 | ||
| 44 | 47 | // Defaults. | |
| @@ -63,6 +66,14 @@ const ( | |||
| 63 | 66 | GitReaderErmiteGit = "ermite-git" | |
| 64 | 67 | ) | |
| 65 | 68 | ||
| 69 | + | // Bounds of ERMITE_CI_POLL_TIMEOUT. The upper bound keeps the poll under the 60 s | |
| 70 | + | // that the runner waits for one FetchJob; the lower one stops runners from | |
| 71 | + | // polling in a tight loop. | |
| 72 | + | const ( | |
| 73 | + | MinCIPollTimeout = time.Second | |
| 74 | + | MaxCIPollTimeout = 55 * time.Second | |
| 75 | + | ) | |
| 76 | + | ||
| 66 | 77 | // MinAdminTokenLen is the shortest accepted admin token, in characters. | |
| 67 | 78 | const MinAdminTokenLen = 32 | |
| 68 | 79 | ||
| @@ -98,6 +109,9 @@ type Config struct { | |||
| 98 | 109 | // CIRetention is how long CI data is kept; 0 (variable unset) leaves the | |
| 99 | 110 | // default to the CI use case. | |
| 100 | 111 | CIRetention time.Duration | |
| 112 | + | // CIPollTimeout is how long FetchJob holds a call; 0 (variable unset) | |
| 113 | + | // leaves the default to the CI use case. | |
| 114 | + | CIPollTimeout time.Duration | |
| 101 | 115 | } | |
| 102 | 116 | ||
| 103 | 117 | // Error lists every configuration problem found, in variable order. | |
| @@ -247,6 +261,18 @@ func Load(get func(string) string) (Config, error) { | |||
| 247 | 261 | } | |
| 248 | 262 | } | |
| 249 | 263 | ||
| 264 | + | if raw := strings.TrimSpace(get(EnvCIPollTimeout)); raw != "" { | |
| 265 | + | d, err := time.ParseDuration(raw) | |
| 266 | + | switch { | |
| 267 | + | case err != nil: | |
| 268 | + | bad(EnvCIPollTimeout, "must be a duration such as 25s (got %q)", raw) | |
| 269 | + | case d < MinCIPollTimeout || d > MaxCIPollTimeout: | |
| 270 | + | bad(EnvCIPollTimeout, "must be between %v and %v (got %q)", MinCIPollTimeout, MaxCIPollTimeout, raw) | |
| 271 | + | default: | |
| 272 | + | c.CIPollTimeout = d | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | ||
| 250 | 276 | if len(problems) > 0 { | |
| 251 | 277 | return Config{}, &Error{Problems: problems} | |
| 252 | 278 | } | |
| @@ -39,6 +39,9 @@ const ( | |||||
| 39 | EnvOwner = "ERMITE_OWNER" | 39 | EnvOwner = "ERMITE_OWNER" | ||
| 40 | // EnvCILogRetention is how long CI runs, jobs, checks and logs are kept. | 40 | // EnvCILogRetention is how long CI runs, jobs, checks and logs are kept. | ||
| 41 | EnvCILogRetention = "ERMITE_CI_LOG_RETENTION" | 41 | EnvCILogRetention = "ERMITE_CI_LOG_RETENTION" | ||
| 42 | + | // EnvCIPollTimeout is how long FetchJob holds a runner's call. Lower it | |||
| 43 | + | // when a reverse proxy cuts idle responses sooner than the default 50 s. | |||
| 44 | + | EnvCIPollTimeout = "ERMITE_CI_POLL_TIMEOUT" | |||
| 42 | ) | 45 | ) | ||
| 43 | 46 | ||||
| 44 | // Defaults. | 47 | // Defaults. | ||
| @@ -63,6 +66,14 @@ const ( | |||||
| 63 | GitReaderErmiteGit = "ermite-git" | 66 | GitReaderErmiteGit = "ermite-git" | ||
| 64 | ) | 67 | ) | ||
| 65 | 68 | ||||
| 69 | + | // Bounds of ERMITE_CI_POLL_TIMEOUT. The upper bound keeps the poll under the 60 s | |||
| 70 | + | // that the runner waits for one FetchJob; the lower one stops runners from | |||
| 71 | + | // polling in a tight loop. | |||
| 72 | + | const ( | |||
| 73 | + | MinCIPollTimeout = time.Second | |||
| 74 | + | MaxCIPollTimeout = 55 * time.Second | |||
| 75 | + | ) | |||
| 76 | + | ||||
| 66 | // MinAdminTokenLen is the shortest accepted admin token, in characters. | 77 | // MinAdminTokenLen is the shortest accepted admin token, in characters. | ||
| 67 | const MinAdminTokenLen = 32 | 78 | const MinAdminTokenLen = 32 | ||
| 68 | 79 | ||||
| @@ -98,6 +109,9 @@ type Config struct { | |||||
| 98 | // CIRetention is how long CI data is kept; 0 (variable unset) leaves the | 109 | // CIRetention is how long CI data is kept; 0 (variable unset) leaves the | ||
| 99 | // default to the CI use case. | 110 | // default to the CI use case. | ||
| 100 | CIRetention time.Duration | 111 | CIRetention time.Duration | ||
| 112 | + | // CIPollTimeout is how long FetchJob holds a call; 0 (variable unset) | |||
| 113 | + | // leaves the default to the CI use case. | |||
| 114 | + | CIPollTimeout time.Duration | |||
| 101 | } | 115 | } | ||
| 102 | 116 | ||||
| 103 | // Error lists every configuration problem found, in variable order. | 117 | // Error lists every configuration problem found, in variable order. | ||
| @@ -247,6 +261,18 @@ func Load(get func(string) string) (Config, error) { | |||||
| 247 | } | 261 | } | ||
| 248 | } | 262 | } | ||
| 249 | 263 | ||||
| 264 | + | if raw := strings.TrimSpace(get(EnvCIPollTimeout)); raw != "" { | |||
| 265 | + | d, err := time.ParseDuration(raw) | |||
| 266 | + | switch { | |||
| 267 | + | case err != nil: | |||
| 268 | + | bad(EnvCIPollTimeout, "must be a duration such as 25s (got %q)", raw) | |||
| 269 | + | case d < MinCIPollTimeout || d > MaxCIPollTimeout: | |||
| 270 | + | bad(EnvCIPollTimeout, "must be between %v and %v (got %q)", MinCIPollTimeout, MaxCIPollTimeout, raw) | |||
| 271 | + | default: | |||
| 272 | + | c.CIPollTimeout = d | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | ||||
| 250 | if len(problems) > 0 { | 276 | if len(problems) > 0 { | ||
| 251 | return Config{}, &Error{Problems: problems} | 277 | return Config{}, &Error{Problems: problems} | ||
| 252 | } | 278 | } | ||
M internal/config/config_test.go +32 -0
| @@ -253,3 +253,35 @@ func TestOwnerAndCIRetention(t *testing.T) { | |||
| 253 | 253 | } | |
| 254 | 254 | } | |
| 255 | 255 | } | |
| 256 | + | ||
| 257 | + | func TestCIPollTimeout(t *testing.T) { | |
| 258 | + | load := func(extra map[string]string) (config.Config, error) { | |
| 259 | + | env := map[string]string{ | |
| 260 | + | config.EnvDataDir: "/data", config.EnvPublicURL: "https://forge.example.org", | |
| 261 | + | config.EnvAdminToken: "0123456789abcdef0123456789abcdef", | |
| 262 | + | } | |
| 263 | + | for k, v := range extra { | |
| 264 | + | env[k] = v | |
| 265 | + | } | |
| 266 | + | return config.Load(func(k string) string { return env[k] }) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | c, err := load(nil) | |
| 270 | + | require.NoError(t, err) | |
| 271 | + | assert.Zero(t, c.CIPollTimeout, "unset: the default is left to the CI use case") | |
| 272 | + | ||
| 273 | + | for in, want := range map[string]time.Duration{ | |
| 274 | + | "25s": 25 * time.Second, " 30s ": 30 * time.Second, "1s": time.Second, "55s": 55 * time.Second, "1500ms": 1500 * time.Millisecond, | |
| 275 | + | } { | |
| 276 | + | c, err = load(map[string]string{config.EnvCIPollTimeout: in}) | |
| 277 | + | require.NoError(t, err, in) | |
| 278 | + | assert.Equal(t, want, c.CIPollTimeout, in) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | for _, in := range []string{"soon", "25", "0s", "-5s", "999ms", "56s", "2m"} { | |
| 282 | + | _, err := load(map[string]string{config.EnvCIPollTimeout: in}) | |
| 283 | + | var ce *config.Error | |
| 284 | + | require.True(t, errors.As(err, &ce), in) | |
| 285 | + | assert.Contains(t, ce.Problems[0], config.EnvCIPollTimeout, in) | |
| 286 | + | } | |
| 287 | + | } | |
| @@ -253,3 +253,35 @@ func TestOwnerAndCIRetention(t *testing.T) { | |||||
| 253 | } | 253 | } | ||
| 254 | } | 254 | } | ||
| 255 | } | 255 | } | ||
| 256 | + | ||||
| 257 | + | func TestCIPollTimeout(t *testing.T) { | |||
| 258 | + | load := func(extra map[string]string) (config.Config, error) { | |||
| 259 | + | env := map[string]string{ | |||
| 260 | + | config.EnvDataDir: "/data", config.EnvPublicURL: "https://forge.example.org", | |||
| 261 | + | config.EnvAdminToken: "0123456789abcdef0123456789abcdef", | |||
| 262 | + | } | |||
| 263 | + | for k, v := range extra { | |||
| 264 | + | env[k] = v | |||
| 265 | + | } | |||
| 266 | + | return config.Load(func(k string) string { return env[k] }) | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | c, err := load(nil) | |||
| 270 | + | require.NoError(t, err) | |||
| 271 | + | assert.Zero(t, c.CIPollTimeout, "unset: the default is left to the CI use case") | |||
| 272 | + | ||||
| 273 | + | for in, want := range map[string]time.Duration{ | |||
| 274 | + | "25s": 25 * time.Second, " 30s ": 30 * time.Second, "1s": time.Second, "55s": 55 * time.Second, "1500ms": 1500 * time.Millisecond, | |||
| 275 | + | } { | |||
| 276 | + | c, err = load(map[string]string{config.EnvCIPollTimeout: in}) | |||
| 277 | + | require.NoError(t, err, in) | |||
| 278 | + | assert.Equal(t, want, c.CIPollTimeout, in) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | for _, in := range []string{"soon", "25", "0s", "-5s", "999ms", "56s", "2m"} { | |||
| 282 | + | _, err := load(map[string]string{config.EnvCIPollTimeout: in}) | |||
| 283 | + | var ce *config.Error | |||
| 284 | + | require.True(t, errors.As(err, &ce), in) | |||
| 285 | + | assert.Contains(t, ce.Problems[0], config.EnvCIPollTimeout, in) | |||
| 286 | + | } | |||
| 287 | + | } | |||