detached 008a3aa

fix(ci): note heartbeats under the run lock and forget the poll record at the ack

Heartbeat now does the acknowledgement and the note of the heartbeat under the
lock of the run, so a heartbeat can no longer be recorded for a job that
ended meanwhile. The record of a poll (which holds the plaintext job token for
the retry of a lost response) is dropped as soon as the assignment is
acknowledged or cancelled. Tests: stale assignment under the lock, the typed
nil, and a retried poll after the acknowledgement.
commit
008a3aabad22e2a8a976960a6f14b964acd5e489
author
brice · · 2026-10-12 19:11 UTC
parent
8d95a49
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +171 -7
M internal/app/ci_flow_test.go +20 -0
@@ -627,3 +627,23 @@ jobs:
627627 require.NoError(t, err)
628628 assert.Nil(t, again, "a cancelled job is not given again")
629629 }
630+
631+// Once the runner acknowledged a job, a retried poll with the same poll_id has
632+// nothing to repeat: the plaintext token is no longer kept.
633+func TestCIAcknowledgedAssignmentIsNotRepeated(t *testing.T) {
634+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 150 * time.Millisecond })
635+ runner := c.registerRunner("alpha", "ubuntu-latest")
636+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
637+ var job *pb.Job
638+ c.waitFor("a job", func() bool {
639+ j, err := runner.fetch("poll-9")
640+ require.NoError(t, err)
641+ job = j
642+ return j != nil
643+ })
644+ _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
645+ require.NoError(t, err)
646+ again, err := runner.fetch("poll-9")
647+ require.NoError(t, err)
648+ assert.Nil(t, again, "the acknowledged assignment is not handed out again")
649+}
@@ -627,3 +627,23 @@ jobs:
627 require.NoError(t, err)627 require.NoError(t, err)
628 assert.Nil(t, again, "a cancelled job is not given again")628 assert.Nil(t, again, "a cancelled job is not given again")
629 }629 }
630+
631+// Once the runner acknowledged a job, a retried poll with the same poll_id has
632+// nothing to repeat: the plaintext token is no longer kept.
633+func TestCIAcknowledgedAssignmentIsNotRepeated(t *testing.T) {
634+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 150 * time.Millisecond })
635+ runner := c.registerRunner("alpha", "ubuntu-latest")
636+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
637+ var job *pb.Job
638+ c.waitFor("a job", func() bool {
639+ j, err := runner.fetch("poll-9")
640+ require.NoError(t, err)
641+ job = j
642+ return j != nil
643+ })
644+ _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
645+ require.NoError(t, err)
646+ again, err := runner.fetch("poll-9")
647+ require.NoError(t, err)
648+ assert.Nil(t, again, "the acknowledged assignment is not handed out again")
649+}
A internal/usecase/ci_runner_unit_test.go +150 -0
@@ -0,0 +1,150 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+ "bnema.dev/ermite/internal/usecase"
18+)
19+
20+// ciUnit is the CI use cases over mocks, with a clock the test moves.
21+type ciUnit struct {
22+ store *mocks.CIStore
23+ logs *mocks.CILogStore
24+ meta *mocks.RepoMetaStore
25+ bus *mocks.EventBus
26+ clock *fakeClock
27+ ci *usecase.CI
28+}
29+
30+type fakeClock struct{ t time.Time }
31+
32+func (c *fakeClock) Now() time.Time { return c.t }
33+
34+func newCIUnit(t *testing.T, tweak func(*usecase.CIConfig)) *ciUnit {
35+ t.Helper()
36+ u := &ciUnit{
37+ store: mocks.NewCIStore(t), logs: mocks.NewCILogStore(t), meta: mocks.NewRepoMetaStore(t),
38+ bus: mocks.NewEventBus(t), clock: &fakeClock{t: now},
39+ }
40+ cfg := usecase.CIConfig{
41+ Store: u.store, Logs: u.logs, Meta: u.meta, Repos: mocks.NewGitRepos(t), Reader: mocks.NewRepoReader(t),
42+ Git: mocks.NewMergeGit(t), Diff: mocks.NewCIGit(t), Bus: u.bus, Clock: u.clock, Owner: "owner", PublicURL: "https://forge.test",
43+ }
44+ if tweak != nil {
45+ tweak(&cfg)
46+ }
47+ u.ci = usecase.NewCI(context.Background(), cfg)
48+ t.Cleanup(func() { u.ci.Close(context.Background()) })
49+ return u
50+}
51+
52+const (
53+ jobSecret = "ermitejob_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN-_0"
54+ jobIDOne = "7-0-0"
55+)
56+
57+func ciJob(t *testing.T, phase domain.CIJobPhase, secret string) domain.CIJob {
58+ t.Helper()
59+ return domain.CIJob{
60+ ID: jobIDOne, RunID: 7, Attempt: 1, RunAttempt: 1, Repo: name(t, "proj"), Key: "build", Name: "build",
61+ Phase: phase, TokenHash: domain.HashTokenSecret(secret), RunnerID: "r1", RunnerName: "alpha", Planned: true,
62+ }
63+}
64+
65+// expectToken makes the token lookup find j.
66+func (u *ciUnit) expectToken(secret string, j domain.CIJob) {
67+ u.store.EXPECT().FindJobByToken(mock.Anything, domain.HashTokenSecret(secret), mock.Anything).Return(j, nil)
68+}
69+
70+// S4: a call whose token was replaced between the lookup and the lock (the
71+// assignment was cancelled and given again) is unauthenticated, for every
72+// call that writes the job.
73+func TestStaleAssignmentIsUnauthenticatedUnderTheLock(t *testing.T) {
74+ t.Parallel()
75+ replaced := func(j domain.CIJob) domain.CIJob {
76+ j.TokenHash = domain.HashTokenSecret("ermitejob_replaced")
77+ return j
78+ }
79+ calls := map[string]func(u *ciUnit) error{
80+ "UpdateJob": func(u *ciUnit) error {
81+ return u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{
82+ Number: 1, State: domain.CIStepInProgress, StartedAt: now,
83+ }})
84+ },
85+ "AppendLog": func(u *ciUnit) error {
86+ _, err := u.ci.AppendLog(context.Background(), jobSecret, jobIDOne, 1, 0, []domain.CILogLine{{Time: now, Text: "x"}})
87+ return err
88+ },
89+ }
90+ for label, call := range calls {
91+ t.Run(label, func(t *testing.T) {
92+ t.Parallel()
93+ u := newCIUnit(t, nil)
94+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
95+ u.expectToken(jobSecret, j)
96+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(replaced(j), nil)
97+ // No write: the stores and the log expect nothing else.
98+ assert.ErrorIs(t, call(u), domain.ErrUnauthenticated)
99+ })
100+ }
101+
102+ t.Run("CompleteJob", func(t *testing.T) {
103+ t.Parallel()
104+ u := newCIUnit(t, nil)
105+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
106+ u.expectToken(jobSecret, j)
107+ u.store.EXPECT().GetRunSource(mock.Anything, j.RunID).Return(domain.CIRunSource{RunID: j.RunID, Repo: j.Repo}, nil)
108+ u.store.EXPECT().GetRun(mock.Anything, j.Repo, j.RunID).Return(domain.CIRun{Repo: j.Repo, ID: j.RunID, Attempt: 1}, nil)
109+ u.store.EXPECT().ListJobs(mock.Anything, j.RunID, int32(1)).Return([]domain.CIJob{replaced(j)}, nil)
110+ _, err := u.ci.CompleteJob(context.Background(), jobSecret, ports.CompleteJobParams{JobID: j.ID, Conclusion: domain.CISuccess})
111+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
112+ })
113+
114+ t.Run("an ended job answers with its end, not unauthenticated", func(t *testing.T) {
115+ t.Parallel()
116+ u := newCIUnit(t, nil)
117+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
118+ ended := replaced(j)
119+ ended.Phase, ended.Conclusion = domain.CIPhaseEnded, domain.CICancelled
120+ u.expectToken(jobSecret, j)
121+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(ended, nil)
122+ err := u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{Number: 1, State: domain.CIStepInProgress, StartedAt: now}})
123+ got, ok := domain.IsJobEnded(err)
124+ require.True(t, ok, "%v", err)
125+ assert.Equal(t, domain.CICancelled, got.Conclusion)
126+ })
127+}
128+
129+// S5: the job vanished while the log limit was handled: a plain not found, never
130+// a typed nil error.
131+func TestLogLimitOnAVanishedJobIsNotFound(t *testing.T) {
132+ t.Parallel()
133+ u := newCIUnit(t, nil)
134+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
135+ u.expectToken(jobSecret, j)
136+ // UpdateJob-style lookup under lock, then a log at the limit.
137+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil)
138+ u.logs.EXPECT().Count(mock.Anything, mock.Anything).Return(0, nil)
139+ u.logs.EXPECT().Append(mock.Anything, mock.Anything, int64(0), mock.Anything, int64(domain.CIMaxLogBytes)).Return(0, 0, ports.ErrCILogLimit)
140+ // stopForLogLimit loads the run, and the job is no longer in it.
141+ u.store.EXPECT().GetRunSource(mock.Anything, j.RunID).Return(domain.CIRunSource{RunID: j.RunID, Repo: j.Repo}, nil)
142+ u.store.EXPECT().GetRun(mock.Anything, j.Repo, j.RunID).Return(domain.CIRun{Repo: j.Repo, ID: j.RunID, Attempt: 1}, nil)
143+ u.store.EXPECT().ListJobs(mock.Anything, j.RunID, int32(1)).Return(nil, nil)
144+
145+ _, err := u.ci.AppendLog(context.Background(), jobSecret, jobIDOne, 1, 0, []domain.CILogLine{{Time: now, Text: "x"}})
146+ require.Error(t, err)
147+ assert.ErrorIs(t, err, domain.ErrNotFound)
148+ _, isEnded := domain.IsJobEnded(err)
149+ assert.False(t, isEnded)
150+}
@@ -0,0 +1,150 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+ "bnema.dev/ermite/internal/usecase"
18+)
19+
20+// ciUnit is the CI use cases over mocks, with a clock the test moves.
21+type ciUnit struct {
22+ store *mocks.CIStore
23+ logs *mocks.CILogStore
24+ meta *mocks.RepoMetaStore
25+ bus *mocks.EventBus
26+ clock *fakeClock
27+ ci *usecase.CI
28+}
29+
30+type fakeClock struct{ t time.Time }
31+
32+func (c *fakeClock) Now() time.Time { return c.t }
33+
34+func newCIUnit(t *testing.T, tweak func(*usecase.CIConfig)) *ciUnit {
35+ t.Helper()
36+ u := &ciUnit{
37+ store: mocks.NewCIStore(t), logs: mocks.NewCILogStore(t), meta: mocks.NewRepoMetaStore(t),
38+ bus: mocks.NewEventBus(t), clock: &fakeClock{t: now},
39+ }
40+ cfg := usecase.CIConfig{
41+ Store: u.store, Logs: u.logs, Meta: u.meta, Repos: mocks.NewGitRepos(t), Reader: mocks.NewRepoReader(t),
42+ Git: mocks.NewMergeGit(t), Diff: mocks.NewCIGit(t), Bus: u.bus, Clock: u.clock, Owner: "owner", PublicURL: "https://forge.test",
43+ }
44+ if tweak != nil {
45+ tweak(&cfg)
46+ }
47+ u.ci = usecase.NewCI(context.Background(), cfg)
48+ t.Cleanup(func() { u.ci.Close(context.Background()) })
49+ return u
50+}
51+
52+const (
53+ jobSecret = "ermitejob_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN-_0"
54+ jobIDOne = "7-0-0"
55+)
56+
57+func ciJob(t *testing.T, phase domain.CIJobPhase, secret string) domain.CIJob {
58+ t.Helper()
59+ return domain.CIJob{
60+ ID: jobIDOne, RunID: 7, Attempt: 1, RunAttempt: 1, Repo: name(t, "proj"), Key: "build", Name: "build",
61+ Phase: phase, TokenHash: domain.HashTokenSecret(secret), RunnerID: "r1", RunnerName: "alpha", Planned: true,
62+ }
63+}
64+
65+// expectToken makes the token lookup find j.
66+func (u *ciUnit) expectToken(secret string, j domain.CIJob) {
67+ u.store.EXPECT().FindJobByToken(mock.Anything, domain.HashTokenSecret(secret), mock.Anything).Return(j, nil)
68+}
69+
70+// S4: a call whose token was replaced between the lookup and the lock (the
71+// assignment was cancelled and given again) is unauthenticated, for every
72+// call that writes the job.
73+func TestStaleAssignmentIsUnauthenticatedUnderTheLock(t *testing.T) {
74+ t.Parallel()
75+ replaced := func(j domain.CIJob) domain.CIJob {
76+ j.TokenHash = domain.HashTokenSecret("ermitejob_replaced")
77+ return j
78+ }
79+ calls := map[string]func(u *ciUnit) error{
80+ "UpdateJob": func(u *ciUnit) error {
81+ return u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{
82+ Number: 1, State: domain.CIStepInProgress, StartedAt: now,
83+ }})
84+ },
85+ "AppendLog": func(u *ciUnit) error {
86+ _, err := u.ci.AppendLog(context.Background(), jobSecret, jobIDOne, 1, 0, []domain.CILogLine{{Time: now, Text: "x"}})
87+ return err
88+ },
89+ }
90+ for label, call := range calls {
91+ t.Run(label, func(t *testing.T) {
92+ t.Parallel()
93+ u := newCIUnit(t, nil)
94+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
95+ u.expectToken(jobSecret, j)
96+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(replaced(j), nil)
97+ // No write: the stores and the log expect nothing else.
98+ assert.ErrorIs(t, call(u), domain.ErrUnauthenticated)
99+ })
100+ }
101+
102+ t.Run("CompleteJob", func(t *testing.T) {
103+ t.Parallel()
104+ u := newCIUnit(t, nil)
105+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
106+ u.expectToken(jobSecret, j)
107+ u.store.EXPECT().GetRunSource(mock.Anything, j.RunID).Return(domain.CIRunSource{RunID: j.RunID, Repo: j.Repo}, nil)
108+ u.store.EXPECT().GetRun(mock.Anything, j.Repo, j.RunID).Return(domain.CIRun{Repo: j.Repo, ID: j.RunID, Attempt: 1}, nil)
109+ u.store.EXPECT().ListJobs(mock.Anything, j.RunID, int32(1)).Return([]domain.CIJob{replaced(j)}, nil)
110+ _, err := u.ci.CompleteJob(context.Background(), jobSecret, ports.CompleteJobParams{JobID: j.ID, Conclusion: domain.CISuccess})
111+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
112+ })
113+
114+ t.Run("an ended job answers with its end, not unauthenticated", func(t *testing.T) {
115+ t.Parallel()
116+ u := newCIUnit(t, nil)
117+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
118+ ended := replaced(j)
119+ ended.Phase, ended.Conclusion = domain.CIPhaseEnded, domain.CICancelled
120+ u.expectToken(jobSecret, j)
121+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(ended, nil)
122+ err := u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{Number: 1, State: domain.CIStepInProgress, StartedAt: now}})
123+ got, ok := domain.IsJobEnded(err)
124+ require.True(t, ok, "%v", err)
125+ assert.Equal(t, domain.CICancelled, got.Conclusion)
126+ })
127+}
128+
129+// S5: the job vanished while the log limit was handled: a plain not found, never
130+// a typed nil error.
131+func TestLogLimitOnAVanishedJobIsNotFound(t *testing.T) {
132+ t.Parallel()
133+ u := newCIUnit(t, nil)
134+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
135+ u.expectToken(jobSecret, j)
136+ // UpdateJob-style lookup under lock, then a log at the limit.
137+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil)
138+ u.logs.EXPECT().Count(mock.Anything, mock.Anything).Return(0, nil)
139+ u.logs.EXPECT().Append(mock.Anything, mock.Anything, int64(0), mock.Anything, int64(domain.CIMaxLogBytes)).Return(0, 0, ports.ErrCILogLimit)
140+ // stopForLogLimit loads the run, and the job is no longer in it.
141+ u.store.EXPECT().GetRunSource(mock.Anything, j.RunID).Return(domain.CIRunSource{RunID: j.RunID, Repo: j.Repo}, nil)
142+ u.store.EXPECT().GetRun(mock.Anything, j.Repo, j.RunID).Return(domain.CIRun{Repo: j.Repo, ID: j.RunID, Attempt: 1}, nil)
143+ u.store.EXPECT().ListJobs(mock.Anything, j.RunID, int32(1)).Return(nil, nil)
144+
145+ _, err := u.ci.AppendLog(context.Background(), jobSecret, jobIDOne, 1, 0, []domain.CILogLine{{Time: now, Text: "x"}})
146+ require.Error(t, err)
147+ assert.ErrorIs(t, err, domain.ErrNotFound)
148+ _, isEnded := domain.IsJobEnded(err)
149+ assert.False(t, isEnded)
150+}
M internal/usecase/ci_watch.go +1 -7
@@ -191,13 +191,7 @@ func (c *CI) cancelAssignment(ctx context.Context, rs *runState, j *domain.CIJob
191191 j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{}
192192 j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone
193193 j.Steps, j.Outputs, j.AssignedAt = nil, nil, time.Time{}
194- c.mu.Lock()
195- for id, p := range c.polls {
196- if p.ref == jobRef(*j) {
197- delete(c.polls, id)
198- }
199- }
200- c.mu.Unlock()
194+ c.forgetPoll(jobRef(*j))
201195 if j.CancelReason != domain.CICancelNone {
202196 // Cancelled (by the owner, fail-fast or the time budget) before the
203197 // runner answered: it is over, not queued again for another runner.
@@ -191,13 +191,7 @@ func (c *CI) cancelAssignment(ctx context.Context, rs *runState, j *domain.CIJob
191 j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{}191 j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{}
192 j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone192 j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone
193 j.Steps, j.Outputs, j.AssignedAt = nil, nil, time.Time{}193 j.Steps, j.Outputs, j.AssignedAt = nil, nil, time.Time{}
194- c.mu.Lock()194+ c.forgetPoll(jobRef(*j))
195- for id, p := range c.polls {
196- if p.ref == jobRef(*j) {
197- delete(c.polls, id)
198- }
199- }
200- c.mu.Unlock()
201 if j.CancelReason != domain.CICancelNone {195 if j.CancelReason != domain.CICancelNone {
202 // Cancelled (by the owner, fail-fast or the time budget) before the196 // Cancelled (by the owner, fail-fast or the time budget) before the
203 // runner answered: it is over, not queued again for another runner.197 // runner answered: it is over, not queued again for another runner.

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 4

Tags 45

↑↓ moveenter openesc close