fix: clear consumed ephemeral registration state (#1256)

Ephemeral registrations are single-use, but the daemon kept `.runner` after the task, so a restarted container failed `Declare` with `unregistered runner` forever.

An ephemeral runner now removes its registration file as soon as it fetches a task, so a container killed mid-job registers afresh on restart. It also removes the file when Gitea answers `unregistered runner` at startup or while polling. A proxy's 401 does not count. Persistent runners keep their file, because Gitea sends the same reply for a token mismatch.

Closes https://gitea.com/gitea/runner/issues/818

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1256
Reviewed-by: silverwind <[email protected]>
This commit is contained in:
bircniandsilverwind committed 2026-10-01 09:23:45 +00:00
1 parent 0cedd98051
commit d93b7d278a
6 files changed
+148 -15

No files matched your search

+12 -8
View File
@@ -52,6 +52,8 @@ type Poller struct {
done chan struct{}
registrationRejected atomic.Bool
// unregistered is set when the server rejects the runner with an
// Unauthenticated response, meaning the runner is no longer registered.
unregistered atomic.Bool
@@ -151,7 +153,7 @@ func (p *Poller) Poll() {
}
}
func (p *Poller) PollOnce() {
func (p *Poller) PollOnce(onTaskFetched func()) {
defer close(p.done)
s := &workerState{}
for {
@@ -173,6 +175,7 @@ func (p *Poller) PollOnce() {
continue
}
s.resetBackoff()
onTaskFetched()
p.runTaskWithRecover(p.jobsCtx, task)
return
}
@@ -191,6 +194,11 @@ func (p *Poller) Unregistered() bool {
return p.unregistered.Load()
}
// RegistrationRejected reports whether polling stopped on an error matching client.IsRegistrationRejected.
func (p *Poller) RegistrationRejected() bool {
return p.registrationRejected.Load()
}
// Ready reports whether the daemon can currently communicate with Gitea and
// accept work, reusing the availability the poll loop last observed rather than
// re-running the check. Transient transport failures are tolerated for grace.
@@ -368,9 +376,11 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
// An Unauthenticated response means the server no longer knows this
// runner (e.g. it was deleted). Retrying forever is pointless, so stop
// polling and let the daemon exit with an error instead of spinning.
if isUnregistered(err) {
rejected := client.IsRegistrationRejected(err)
if rejected || connect.CodeOf(err) == connect.CodeUnauthenticated {
log.WithError(err).Error("server rejected the runner as unregistered, stopping poller")
p.unregistered.Store(true)
p.registrationRejected.Store(rejected)
p.shutdownPolling()
return nil, false
}
@@ -415,12 +425,6 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
return resp.Msg.Task, true
}
func isUnregistered(err error) bool {
var connectErr *connect.Error
return errors.As(err, &connectErr) && (connectErr.Code() == connect.CodeUnauthenticated ||
connectErr.Code() == connect.CodeUnknown && connect.IsWireError(err) && connectErr.Message() == "rpc error: code = Unauthenticated desc = unregistered runner")
}
func (p *Poller) markHealthyPoll() {
p.lastHealthyPoll.Store(time.Now().UnixNano())
p.lastPollFailed.Store(false)
+21 -5
View File
@@ -106,13 +106,16 @@ func TestPoller_FetchTimeoutIsNoSignal(t *testing.T) {
// response marks the runner as unregistered and cancels the polling context so
// the daemon can exit instead of retrying forever.
func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
for name, fetchErr := range map[string]error{
"connect": connect_go.NewError(connect_go.CodeUnauthenticated, errors.New("unregistered runner")),
"gitea": connect_go.NewWireError(connect_go.CodeUnknown, errors.New("rpc error: code = Unauthenticated desc = unregistered runner")),
for name, tc := range map[string]struct {
fetchErr error
rejected bool
}{
"connect": {connect_go.NewError(connect_go.CodeUnauthenticated, errors.New("unregistered runner")), false},
"gitea": {connect_go.NewWireError(connect_go.CodeUnknown, errors.New("rpc error: code = Unauthenticated desc = unregistered runner")), true},
} {
t.Run(name, func(t *testing.T) {
client := mocks.NewClient(t)
client.On("FetchTask", mock.Anything, mock.Anything).Return(nil, fetchErr)
client.On("FetchTask", mock.Anything, mock.Anything).Return(nil, tc.fetchErr)
cfg, err := config.LoadDefault("")
require.NoError(t, err)
@@ -122,6 +125,7 @@ func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
require.False(t, ok)
assert.True(t, p.Unregistered(), "runner should be marked unregistered")
assert.Equal(t, tc.rejected, p.RegistrationRejected())
assert.Equal(t, int64(0), s.consecutiveErrors, "unauthenticated must not drive error backoff")
select {
case <-p.pollingCtx.Done():
@@ -330,7 +334,7 @@ func TestPollerPollOnceCallsOnIdle(t *testing.T) {
poller := New(cfg, cli, runner)
var wg sync.WaitGroup
wg.Go(poller.PollOnce)
wg.Go(func() { poller.PollOnce(func() {}) })
require.Eventually(t, func() bool {
return runner.idleCalls.Load() > 0
@@ -342,6 +346,18 @@ func TestPollerPollOnceCallsOnIdle(t *testing.T) {
wg.Wait()
}
func TestPollerPollOnceCallsOnTaskFetchedBeforeRunningIt(t *testing.T) {
cli := mocks.NewClient(t)
cli.On("FetchTask", mock.Anything, mock.Anything).Return(connect_go.NewResponse(&runnerv1.FetchTaskResponse{Task: &runnerv1.Task{Id: 1}}), nil)
cfg, err := config.LoadDefault("")
require.NoError(t, err)
runner := &mockRunner{}
runsSeenByCallback := int64(-1)
New(cfg, cli, runner).PollOnce(func() { runsSeenByCallback = runner.running.Load() + runner.totalCompleted.Load() })
assert.Zero(t, runsSeenByCallback)
assert.Equal(t, int64(1), runner.totalCompleted.Load())
}
// TestPoller_ConcurrencyLimitedByCapacity verifies that with capacity=3 and
// 6 available tasks, at most 3 tasks run concurrently, and FetchTask is
// never called concurrently (single poller).