detached e30ca52

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +60 -0
M README.md +1 -0
@@ -29,6 +29,7 @@ ermite serve
2929 | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges |
3030 | `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names |
3131 | `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) |
3233
3334 `ermite version` prints the version (and the server's when a server URL is configured, see Remote CLI).
3435
@@ -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
199199 Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
200200 Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
201201 Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,
202+ PollTimeout: cfg.CIPollTimeout,
202203 }
203204 if opts.ci != nil {
204205 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 (
3939 EnvOwner = "ERMITE_OWNER"
4040 // EnvCILogRetention is how long CI runs, jobs, checks and logs are kept.
4141 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"
4245 )
4346
4447 // Defaults.
@@ -63,6 +66,14 @@ const (
6366 GitReaderErmiteGit = "ermite-git"
6467 )
6568
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+
6677 // MinAdminTokenLen is the shortest accepted admin token, in characters.
6778 const MinAdminTokenLen = 32
6879
@@ -98,6 +109,9 @@ type Config struct {
98109 // CIRetention is how long CI data is kept; 0 (variable unset) leaves the
99110 // default to the CI use case.
100111 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
101115 }
102116
103117 // Error lists every configuration problem found, in variable order.
@@ -247,6 +261,18 @@ func Load(get func(string) string) (Config, error) {
247261 }
248262 }
249263
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+
250276 if len(problems) > 0 {
251277 return Config{}, &Error{Problems: problems}
252278 }
@@ -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 = 3278 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 the109 // 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.Duration111 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) {
253253 }
254254 }
255255 }
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+}

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 2

Tags 41

↑↓ moveenter openesc close