@@ -26,6 +26,14 @@ const (
2626 RunnerStateFailed RunnerState = "failed"
2727)
2828
29+ const (
30+ // BusyRunnerGracePeriod is the time to wait for busy runners to complete
31+ // their job before forcefully stopping them during shutdown.
32+ // NOTE: This must be less than systemd's TimeoutStopSec (default 150s in services.nix)
33+ // to allow time for cleanup after the grace period expires.
34+ BusyRunnerGracePeriod = 2 * time .Minute
35+ )
36+
2937// RunnerInfo contains information about a single runner VM.
3038type RunnerInfo struct {
3139 ID string `json:"id"`
@@ -138,45 +146,119 @@ func (p *Pool) Start(ctx context.Context) error {
138146// Stop gracefully stops the pool and all runners.
139147// This includes unregistering active runners from Gitea and destroying VMs.
140148// Runners that already completed are deregistered by monitorRunner, so we skip them here.
149+ // Busy runners (checked via Gitea API) are given a grace period to complete their job.
141150func (p * Pool ) Stop () error {
142151 p .cancel ()
143152 if p .scaleTicker != nil {
144153 p .scaleTicker .Stop ()
145154 }
146155 p .wg .Wait ()
147156
148- // Stop all runners
149- p .mu .Lock ()
150- defer p .mu .Unlock ()
151-
152157 // Create a context with timeout for shutdown operations
153158 shutdownCtx , cancel := context .WithTimeout (context .Background (), 30 * time .Second )
154159 defer cancel ()
155160
156- for id , runner := range p .runners {
161+ // Collect runners to stop (take a snapshot to avoid holding lock during operations)
162+ p .mu .Lock ()
163+ var activeRunners []* RunnerInfo
164+ for _ , runner := range p .runners {
157165 // Skip runners that already completed - monitorRunner already deregistered them from Gitea
158166 if runner .Status == RunnerStateStopped || runner .Status == RunnerStateFailed {
159- p .log .Debugf ("Skipping cleanup for completed runner %s (status: %s)" , id , runner .Status )
167+ p .log .Debugf ("Skipping cleanup for completed runner %s (status: %s)" , runner . ID , runner .Status )
160168 continue
161169 }
170+ activeRunners = append (activeRunners , runner )
171+ }
172+ p .mu .Unlock ()
162173
163- // For active runners, try to unregister from Gitea
164- // This prevents the runner from showing as offline in Gitea UI
165- if runner .Name != "" {
166- p .log .Infof ("Unregistering active runner %s from Gitea" , runner .Name )
167- if err := p .gitea .DeleteRunnerByName (shutdownCtx , runner .Name ); err != nil {
168- p .log .Warnf ("Failed to unregister runner %s from Gitea: %v" , runner .Name , err )
169- // Continue with VM destruction even if unregistration fails
174+ if len (activeRunners ) == 0 {
175+ // Close the VM manager
176+ if p .vmManager != nil {
177+ if err := p .vmManager .Close (); err != nil {
178+ p .log .Errorf ("Failed to close VM manager: %v" , err )
170179 }
171180 }
181+ return nil
182+ }
183+
184+ // Check Gitea API to determine which runners are actually busy
185+ var idleRunners , busyRunners []* RunnerInfo
186+ for _ , runner := range activeRunners {
187+ giteaRunner , err := p .gitea .GetRunnerByName (shutdownCtx , runner .Name )
188+ if err != nil {
189+ p .log .Warnf ("Failed to check runner %s status from Gitea: %v, treating as idle" , runner .Name , err )
190+ idleRunners = append (idleRunners , runner )
191+ continue
192+ }
193+ if giteaRunner == nil {
194+ // Runner not found in Gitea - already deregistered or never registered
195+ p .log .Debugf ("Runner %s not found in Gitea, treating as idle" , runner .Name )
196+ idleRunners = append (idleRunners , runner )
197+ continue
198+ }
199+ if giteaRunner .Busy {
200+ p .log .WithFields (logrus.Fields {
201+ "runner_name" : runner .Name ,
202+ "gitea_id" : giteaRunner .ID ,
203+ }).Info ("Runner is busy, will wait for job completion" )
204+ busyRunners = append (busyRunners , runner )
205+ } else {
206+ idleRunners = append (idleRunners , runner )
207+ }
208+ }
172209
173- // Destroy the VM if it's still running
174- if runner .VMID != "" {
175- p .log .Infof ("Stopping runner %s (VM: %s)" , id , runner .VMID )
176- if err := p .vmManager .DestroyVM (runner .VMID ); err != nil {
177- p .log .Errorf ("Failed to destroy VM %s: %v" , runner .VMID , err )
210+ // Immediately stop idle runners
211+ for _ , runner := range idleRunners {
212+ p .stopRunner (shutdownCtx , runner )
213+ }
214+
215+ // Give busy runners a grace period to complete their job
216+ if len (busyRunners ) > 0 {
217+ p .log .WithField ("count" , len (busyRunners )).Infof (
218+ "Waiting up to %v for busy runners to complete their jobs" , BusyRunnerGracePeriod )
219+
220+ // Wait for busy runners with a grace period
221+ graceCtx , graceCancel := context .WithTimeout (context .Background (), BusyRunnerGracePeriod )
222+ defer graceCancel ()
223+
224+ // Check periodically if busy runners have completed (via Gitea API)
225+ ticker := time .NewTicker (5 * time .Second )
226+ defer ticker .Stop ()
227+
228+ waitLoop:
229+ for {
230+ select {
231+ case <- graceCtx .Done ():
232+ // Grace period expired
233+ p .log .Warn ("Grace period expired, forcefully stopping remaining busy runners" )
234+ break waitLoop
235+ case <- ticker .C :
236+ // Check Gitea API to see if runners are still busy
237+ stillBusy := 0
238+ for _ , runner := range busyRunners {
239+ giteaRunner , err := p .gitea .GetRunnerByName (shutdownCtx , runner .Name )
240+ if err != nil {
241+ p .log .Warnf ("Failed to check runner %s status: %v" , runner .Name , err )
242+ stillBusy ++ // Assume still busy on error
243+ continue
244+ }
245+ if giteaRunner != nil && giteaRunner .Busy {
246+ stillBusy ++
247+ }
248+ }
249+
250+ if stillBusy == 0 {
251+ p .log .Info ("All busy runners completed their jobs" )
252+ break waitLoop
253+ }
254+ p .log .WithField ("remaining" , stillBusy ).Debug ("Still waiting for busy runners" )
178255 }
179256 }
257+
258+ // Stop all busy runners (whether completed or grace period expired)
259+ for _ , runner := range busyRunners {
260+ p .stopRunner (shutdownCtx , runner )
261+ }
180262 }
181263
182264 // Close the VM manager
@@ -189,6 +271,33 @@ func (p *Pool) Stop() error {
189271 return nil
190272}
191273
274+ // stopRunner unregisters a runner from Gitea and destroys its VM.
275+ // Note: This does not hold the pool mutex, so it's safe to call from Stop().
276+ func (p * Pool ) stopRunner (ctx context.Context , runner * RunnerInfo ) {
277+ // Try to unregister from Gitea
278+ // This prevents the runner from showing as offline in Gitea UI
279+ if runner .Name != "" {
280+ p .log .Infof ("Unregistering runner %s from Gitea" , runner .Name )
281+ if err := p .gitea .DeleteRunnerByName (ctx , runner .Name ); err != nil {
282+ p .log .Warnf ("Failed to unregister runner %s from Gitea: %v" , runner .Name , err )
283+ // Continue with VM destruction even if unregistration fails
284+ }
285+ }
286+
287+ // Destroy the VM if it's still running
288+ if runner .VMID != "" {
289+ p .log .Infof ("Stopping runner %s (VM: %s)" , runner .ID , runner .VMID )
290+ if err := p .vmManager .DestroyVM (runner .VMID ); err != nil {
291+ p .log .Errorf ("Failed to destroy VM %s: %v" , runner .VMID , err )
292+ }
293+ }
294+
295+ // Update status with lock
296+ p .mu .Lock ()
297+ runner .Status = RunnerStateStopped
298+ p .mu .Unlock ()
299+ }
300+
192301// Pause pauses the pool. Pausing prevents the pool from scaling.
193302func (p * Pool ) Pause () {
194303 p .mu .Lock ()
@@ -487,28 +596,34 @@ func (p *Pool) getRunnerName(runnerID string) string {
487596// monitorRunner watches a runner VM and cleans up when it exits.
488597// When a job completes, act_runner (with --once flag) exits, causing the VM to terminate.
489598// This triggers cleanup including Gitea deregistration, and the scaling loop spawns a replacement.
599+ // If context is cancelled (shutdown), cleanup is skipped - Stop() handles it.
490600func (p * Pool ) monitorRunner (runnerID , vmID string , startTime time.Time ) {
491601 // Wait for VM to exit - act_runner with --once flag exits after completing a job
492602 err := p .vmManager .WaitForExit (p .ctx , vmID )
493603
494604 lifetime := time .Since (startTime )
495605
606+ // If context was cancelled, Stop() will handle all cleanup
607+ // Just log and return - don't race with Stop()
608+ if p .ctx .Err () != nil {
609+ p .log .WithFields (logrus.Fields {
610+ "runner_id" : runnerID ,
611+ "vm_id" : vmID ,
612+ "lifetime" : lifetime .Round (time .Second ),
613+ }).Debug ("Monitor exiting due to shutdown, Stop() will handle cleanup" )
614+ return
615+ }
616+
496617 // Get runner name for Gitea cleanup before updating status
497618 runnerName := p .getRunnerName (runnerID )
498619
499- if err != nil && p . ctx . Err () == nil {
620+ if err != nil {
500621 p .log .WithFields (logrus.Fields {
501622 "runner_id" : runnerID ,
502623 "vm_id" : vmID ,
503624 "lifetime" : lifetime .Round (time .Second ),
504625 "error" : err ,
505626 }).Error ("Runner VM exited with error" )
506- } else if p .ctx .Err () != nil {
507- p .log .WithFields (logrus.Fields {
508- "runner_id" : runnerID ,
509- "vm_id" : vmID ,
510- "lifetime" : lifetime .Round (time .Second ),
511- }).Info ("Runner stopped due to shutdown signal" )
512627 } else {
513628 p .log .WithFields (logrus.Fields {
514629 "runner_id" : runnerID ,
@@ -518,8 +633,7 @@ func (p *Pool) monitorRunner(runnerID, vmID string, startTime time.Time) {
518633 }
519634
520635 // Deregister runner from Gitea (not using --ephemeral, so manual cleanup required)
521- // Skip if shutting down - Stop() handles cleanup for active runners
522- if p .ctx .Err () == nil && runnerName != "" {
636+ if runnerName != "" {
523637 cleanupCtx , cancel := context .WithTimeout (context .Background (), 10 * time .Second )
524638 defer cancel ()
525639 if err := p .gitea .DeleteRunnerByName (cleanupCtx , runnerName ); err != nil {
@@ -550,15 +664,12 @@ func (p *Pool) monitorRunner(runnerID, vmID string, startTime time.Time) {
550664 }).Info ("VM resources cleaned up, runner slot available for replacement" )
551665 }
552666
553- // Signal immediate scaling if not shutting down
554- // This avoids waiting for the next scaling loop tick (up to 10s delay)
555- if p .ctx .Err () == nil {
556- select {
557- case p .scaleSignal <- struct {}{}:
558- // Signal sent successfully
559- default :
560- // Channel already has a signal pending, no need to send another
561- }
667+ // Signal immediate scaling to spawn replacement runner
668+ select {
669+ case p .scaleSignal <- struct {}{}:
670+ // Signal sent successfully
671+ default :
672+ // Channel already has a signal pending, no need to send another
562673 }
563674}
564675
0 commit comments