detached 8d95a49

fix(ci): check the assignment under the run lock and never return a typed nil

UpdateJob, AppendLog and CompleteJob compared the token only before taking the
lock, so a call from a runner whose assignment was cancelled and given again
could still write the job. They now compare it under the lock, like
Heartbeat, unless the job has ended. stopForLogLimit returned a nil
*JobEndedError as an error when the job had vanished: it is now not found.
commit
8d95a4923a16f83622b5feec991c3b481d3f70e4
author
brice · · 2026-10-12 19:11 UTC
parent
9728ecc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +65 -27
M internal/usecase/ci_runner.go +65 -27
@@ -303,9 +303,23 @@ func (c *CI) lockedJob(ctx context.Context, j domain.CIJob, fn func(cur *domain.
303303 if err != nil {
304304 return err
305305 }
306+ if err := sameAssignment(j, cur); err != nil {
307+ return err
308+ }
306309 return fn(&cur)
307310 }
308311
312+// sameAssignment checks, under the lock of the run, that the job a token was
313+// found for is still given under that token: an assignment cancelled and given
314+// again since has a new one, and the old runner is unauthenticated. A job that
315+// has ended answers with its end instead (the callers do).
316+func sameAssignment(was, cur domain.CIJob) error {
317+ if !cur.Ended() && cur.TokenHash != was.TokenHash {
318+ return domain.ErrUnauthenticated
319+ }
320+ return nil
321+}
322+
309323 // saveJob writes one job (its steps changed) and publishes JobUpdated. The
310324 // status of the run does not depend on steps.
311325 func (c *CI) saveJob(ctx context.Context, j *domain.CIJob) error {
@@ -562,12 +576,16 @@ func (c *CI) stopForLogLimit(ctx context.Context, j domain.CIJob) error {
562576 var ended *domain.JobEndedError
563577 err := c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
564578 cur := rs.find(j.ID)
565- if cur == nil || cur.Ended() {
566- if cur != nil {
567- ended = &domain.JobEndedError{Conclusion: cur.Conclusion, Message: cur.Error}
568- }
579+ if cur == nil {
580+ return domain.ErrNotFound
581+ }
582+ if cur.Ended() {
583+ ended = &domain.JobEndedError{Conclusion: cur.Conclusion, Message: cur.Error}
569584 return nil
570585 }
586+ if err := sameAssignment(j, *cur); err != nil {
587+ return err
588+ }
571589 _, _ = c.cfg.Logs.AppendForced(ctx, logRef(*cur), []domain.CILogLine{{Time: c.now(), Text: "##[error]" + domain.CILogLimitMessage}})
572590 rs.end(cur, domain.CIFailure, domain.CILogLimitMessage)
573591 ended = &domain.JobEndedError{Conclusion: domain.CIFailure, Message: domain.CILogLimitMessage}
@@ -579,6 +597,9 @@ func (c *CI) stopForLogLimit(ctx context.Context, j domain.CIJob) error {
579597 if err != nil {
580598 return err
581599 }
600+ if ended == nil {
601+ return domain.ErrNotFound // a typed nil would be taken for an error
602+ }
582603 return ended
583604 }
584605
@@ -592,33 +613,47 @@ func (c *CI) Heartbeat(ctx context.Context, secret, jobID string) error {
592613 if j.Ended() {
593614 return endedError(j)
594615 }
595- now := c.now()
596- if j.Phase == domain.CIPhaseAssigned {
597- err := c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
598- cur := rs.find(j.ID)
599- switch {
600- case cur == nil:
601- return domain.ErrUnauthenticated
602- case cur.Ended():
603- return endedError(*cur)
604- case cur.TokenHash != j.TokenHash:
605- return domain.ErrUnauthenticated // the assignment was cancelled and given again
606- case cur.Phase != domain.CIPhaseAssigned:
607- return nil // acknowledged by a concurrent call
608- }
616+ // Everything is done under the lock of the run: the acknowledgement, and
617+ // the note of the heartbeat, which must not be written once the job ended
618+ // (end() forgets it under the same lock).
619+ return c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
620+ cur := rs.find(j.ID)
621+ switch {
622+ case cur == nil:
623+ return domain.ErrUnauthenticated
624+ case cur.Ended():
625+ return endedError(*cur)
626+ case cur.TokenHash != j.TokenHash:
627+ return domain.ErrUnauthenticated // the assignment was cancelled and given again
628+ }
629+ now := c.now()
630+ if cur.Phase == domain.CIPhaseAssigned {
609631 cur.Phase, cur.AckedAt, cur.StartedAt, cur.AssignCancels = domain.CIPhaseRunning, now, now, 0
610632 rs.touch(cur)
611- return rs.flush(ctx)
612- })
613- if err != nil {
614- return err
633+ if err := rs.flush(ctx); err != nil {
634+ return err
635+ }
636+ c.forgetPoll(jobRef(*cur)) // the token it holds is the runner's now
615637 }
616- }
638+ c.mu.Lock()
639+ c.heartbeats[jobRef(*cur)] = now
640+ c.seen[cur.RunnerID] = now
641+ c.mu.Unlock()
642+ return nil
643+ })
644+}
645+
646+// forgetPoll drops the remembered assignment (and its plaintext token) of a
647+// job: after the acknowledgement or the cancel of the assignment, a retried
648+// poll has nothing to repeat.
649+func (c *CI) forgetPoll(ref string) {
617650 c.mu.Lock()
618- c.heartbeats[jobRef(j)] = now
619- c.seen[j.RunnerID] = now
620- c.mu.Unlock()
621- return nil
651+ defer c.mu.Unlock()
652+ for id, p := range c.polls {
653+ if p.ref == ref {
654+ delete(c.polls, id)
655+ }
656+ }
622657 }
623658
624659 // WatchJob streams what the runner must react to: the cancel request (the
@@ -705,6 +740,9 @@ func (c *CI) CompleteJob(ctx context.Context, secret string, params ports.Comple
705740 res = ports.CompleteJobResult{Conclusion: cur.Conclusion, Error: cur.Error}
706741 return nil
707742 }
743+ if err := sameAssignment(j, *cur); err != nil {
744+ return err
745+ }
708746 steps, _, err := applySteps(cur.Steps, params.Steps)
709747 if err != nil {
710748 return err
@@ -303,9 +303,23 @@ func (c *CI) lockedJob(ctx context.Context, j domain.CIJob, fn func(cur *domain.
303 if err != nil {303 if err != nil {
304 return err304 return err
305 }305 }
306+ if err := sameAssignment(j, cur); err != nil {
307+ return err
308+ }
306 return fn(&cur)309 return fn(&cur)
307 }310 }
308 311
312+// sameAssignment checks, under the lock of the run, that the job a token was
313+// found for is still given under that token: an assignment cancelled and given
314+// again since has a new one, and the old runner is unauthenticated. A job that
315+// has ended answers with its end instead (the callers do).
316+func sameAssignment(was, cur domain.CIJob) error {
317+ if !cur.Ended() && cur.TokenHash != was.TokenHash {
318+ return domain.ErrUnauthenticated
319+ }
320+ return nil
321+}
322+
309 // saveJob writes one job (its steps changed) and publishes JobUpdated. The323 // saveJob writes one job (its steps changed) and publishes JobUpdated. The
310 // status of the run does not depend on steps.324 // status of the run does not depend on steps.
311 func (c *CI) saveJob(ctx context.Context, j *domain.CIJob) error {325 func (c *CI) saveJob(ctx context.Context, j *domain.CIJob) error {
@@ -562,12 +576,16 @@ func (c *CI) stopForLogLimit(ctx context.Context, j domain.CIJob) error {
562 var ended *domain.JobEndedError576 var ended *domain.JobEndedError
563 err := c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {577 err := c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
564 cur := rs.find(j.ID)578 cur := rs.find(j.ID)
565- if cur == nil || cur.Ended() {579+ if cur == nil {
566- if cur != nil {580+ return domain.ErrNotFound
567- ended = &domain.JobEndedError{Conclusion: cur.Conclusion, Message: cur.Error}581+ }
568- }582+ if cur.Ended() {
583+ ended = &domain.JobEndedError{Conclusion: cur.Conclusion, Message: cur.Error}
569 return nil584 return nil
570 }585 }
586+ if err := sameAssignment(j, *cur); err != nil {
587+ return err
588+ }
571 _, _ = c.cfg.Logs.AppendForced(ctx, logRef(*cur), []domain.CILogLine{{Time: c.now(), Text: "##[error]" + domain.CILogLimitMessage}})589 _, _ = c.cfg.Logs.AppendForced(ctx, logRef(*cur), []domain.CILogLine{{Time: c.now(), Text: "##[error]" + domain.CILogLimitMessage}})
572 rs.end(cur, domain.CIFailure, domain.CILogLimitMessage)590 rs.end(cur, domain.CIFailure, domain.CILogLimitMessage)
573 ended = &domain.JobEndedError{Conclusion: domain.CIFailure, Message: domain.CILogLimitMessage}591 ended = &domain.JobEndedError{Conclusion: domain.CIFailure, Message: domain.CILogLimitMessage}
@@ -579,6 +597,9 @@ func (c *CI) stopForLogLimit(ctx context.Context, j domain.CIJob) error {
579 if err != nil {597 if err != nil {
580 return err598 return err
581 }599 }
600+ if ended == nil {
601+ return domain.ErrNotFound // a typed nil would be taken for an error
602+ }
582 return ended603 return ended
583 }604 }
584 605
@@ -592,33 +613,47 @@ func (c *CI) Heartbeat(ctx context.Context, secret, jobID string) error {
592 if j.Ended() {613 if j.Ended() {
593 return endedError(j)614 return endedError(j)
594 }615 }
595- now := c.now()616+ // Everything is done under the lock of the run: the acknowledgement, and
596- if j.Phase == domain.CIPhaseAssigned {617+ // the note of the heartbeat, which must not be written once the job ended
597- err := c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {618+ // (end() forgets it under the same lock).
598- cur := rs.find(j.ID)619+ return c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
599- switch {620+ cur := rs.find(j.ID)
600- case cur == nil:621+ switch {
601- return domain.ErrUnauthenticated622+ case cur == nil:
602- case cur.Ended():623+ return domain.ErrUnauthenticated
603- return endedError(*cur)624+ case cur.Ended():
604- case cur.TokenHash != j.TokenHash:625+ return endedError(*cur)
605- return domain.ErrUnauthenticated // the assignment was cancelled and given again626+ case cur.TokenHash != j.TokenHash:
606- case cur.Phase != domain.CIPhaseAssigned:627+ return domain.ErrUnauthenticated // the assignment was cancelled and given again
607- return nil // acknowledged by a concurrent call628+ }
608- }629+ now := c.now()
630+ if cur.Phase == domain.CIPhaseAssigned {
609 cur.Phase, cur.AckedAt, cur.StartedAt, cur.AssignCancels = domain.CIPhaseRunning, now, now, 0631 cur.Phase, cur.AckedAt, cur.StartedAt, cur.AssignCancels = domain.CIPhaseRunning, now, now, 0
610 rs.touch(cur)632 rs.touch(cur)
611- return rs.flush(ctx)633+ if err := rs.flush(ctx); err != nil {
612- })634+ return err
613- if err != nil {635+ }
614- return err636+ c.forgetPoll(jobRef(*cur)) // the token it holds is the runner's now
615 }637 }
616- }638+ c.mu.Lock()
639+ c.heartbeats[jobRef(*cur)] = now
640+ c.seen[cur.RunnerID] = now
641+ c.mu.Unlock()
642+ return nil
643+ })
644+}
645+
646+// forgetPoll drops the remembered assignment (and its plaintext token) of a
647+// job: after the acknowledgement or the cancel of the assignment, a retried
648+// poll has nothing to repeat.
649+func (c *CI) forgetPoll(ref string) {
617 c.mu.Lock()650 c.mu.Lock()
618- c.heartbeats[jobRef(j)] = now651+ defer c.mu.Unlock()
619- c.seen[j.RunnerID] = now652+ for id, p := range c.polls {
620- c.mu.Unlock()653+ if p.ref == ref {
621- return nil654+ delete(c.polls, id)
655+ }
656+ }
622 }657 }
623 658
624 // WatchJob streams what the runner must react to: the cancel request (the659 // WatchJob streams what the runner must react to: the cancel request (the
@@ -705,6 +740,9 @@ func (c *CI) CompleteJob(ctx context.Context, secret string, params ports.Comple
705 res = ports.CompleteJobResult{Conclusion: cur.Conclusion, Error: cur.Error}740 res = ports.CompleteJobResult{Conclusion: cur.Conclusion, Error: cur.Error}
706 return nil741 return nil
707 }742 }
743+ if err := sameAssignment(j, *cur); err != nil {
744+ return err
745+ }
708 steps, _, err := applySteps(cur.Steps, params.Steps)746 steps, _, err := applySteps(cur.Steps, params.Steps)
709 if err != nil {747 if err != nil {
710 return err748 return err

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 44

↑↓ moveenter openesc close