detached a21f495

fix(host): enforce the job timeout from the receipt of the job and send the remaining time to the executor (M6, m1)

commit
a21f4957235374bd440f4d24fb993f11fb207d73
author
brice · · 2026-10-12 19:27 UTC
parent
fd7cbe1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +192 -24
M internal/execstream/exec.proto +6 -0
@@ -60,6 +60,12 @@ message JobSpec {
6060 // runner_environment is the runner.environment value ("self-hosted").
6161 string runner_environment = 10;
6262 bool debug = 11; // ACTIONS_STEP_DEBUG
63+ // deadline_ms is what is left of the job timeout when the host sends this
64+ // Run, in milliseconds: the timeout counts from the moment the host received
65+ // the job (runner.proto: Job.timeout_minutes), not from the start of the
66+ // executor, and booting the sandbox takes time. 0 means "use
67+ // timeout_minutes from now".
68+ int64 deadline_ms = 12;
6369 }
6470
6571 enum StreamKind {
@@ -60,6 +60,12 @@ message JobSpec {
60 // runner_environment is the runner.environment value ("self-hosted").60 // runner_environment is the runner.environment value ("self-hosted").
61 string runner_environment = 10;61 string runner_environment = 10;
62 bool debug = 11; // ACTIONS_STEP_DEBUG62 bool debug = 11; // ACTIONS_STEP_DEBUG
63+ // deadline_ms is what is left of the job timeout when the host sends this
64+ // Run, in milliseconds: the timeout counts from the moment the host received
65+ // the job (runner.proto: Job.timeout_minutes), not from the start of the
66+ // executor, and booting the sandbox takes time. 0 means "use
67+ // timeout_minutes from now".
68+ int64 deadline_ms = 12;
63 }69 }
64 70
65 enum StreamKind {71 enum StreamKind {
M internal/execstream/execpb/exec.pb.go +19 -4 generated file: collapsed
M internal/executor/job.go +10 -5
@@ -50,8 +50,13 @@ type Config struct {
5050 MatrixJSON string
5151 // Repo is the repository name used when the github context lacks one.
5252 Repo string
53- // TimeoutMinutes is the job timeout; 0 means DefaultTimeoutMinutes.
53+ // TimeoutMinutes is the job timeout; 0 means DefaultTimeoutMinutes. It is
54+ // used for the message and, without Deadline, for the timer.
5455 TimeoutMinutes int
56+ // Deadline is the time left of the job timeout when Run is called, when the
57+ // timeout started counting earlier (at the receipt of the job by the host);
58+ // 0 means TimeoutMinutes from now.
59+ Deadline time.Duration
5560
5661 Runner RunnerInfo
5762 RunnerVersion string
@@ -165,11 +170,11 @@ func Run(cancel *Canceller, cfg Config) Result {
165170 }
166171
167172 func (j *job) run() Result {
168- minutes := j.cfg.TimeoutMinutes
169- if minutes <= 0 {
170- minutes = DefaultTimeoutMinutes
173+ limit := j.cfg.Deadline
174+ if limit <= 0 {
175+ limit = time.Duration(j.timeoutMinutes()) * time.Minute
171176 }
172- timer := time.AfterFunc(time.Duration(minutes)*time.Minute, func() { j.cancel.Cancel(CancelTimeout) })
177+ timer := time.AfterFunc(limit, func() { j.cancel.Cancel(CancelTimeout) })
173178 defer timer.Stop()
174179
175180 // ---- Set up job (JobExtension.InitializeJob) ----
@@ -50,8 +50,13 @@ type Config struct {
50 MatrixJSON string50 MatrixJSON string
51 // Repo is the repository name used when the github context lacks one.51 // Repo is the repository name used when the github context lacks one.
52 Repo string52 Repo string
53- // TimeoutMinutes is the job timeout; 0 means DefaultTimeoutMinutes.53+ // TimeoutMinutes is the job timeout; 0 means DefaultTimeoutMinutes. It is
54+ // used for the message and, without Deadline, for the timer.
54 TimeoutMinutes int55 TimeoutMinutes int
56+ // Deadline is the time left of the job timeout when Run is called, when the
57+ // timeout started counting earlier (at the receipt of the job by the host);
58+ // 0 means TimeoutMinutes from now.
59+ Deadline time.Duration
55 60
56 Runner RunnerInfo61 Runner RunnerInfo
57 RunnerVersion string62 RunnerVersion string
@@ -165,11 +170,11 @@ func Run(cancel *Canceller, cfg Config) Result {
165 }170 }
166 171
167 func (j *job) run() Result {172 func (j *job) run() Result {
168- minutes := j.cfg.TimeoutMinutes173+ limit := j.cfg.Deadline
169- if minutes <= 0 {174+ if limit <= 0 {
170- minutes = DefaultTimeoutMinutes175+ limit = time.Duration(j.timeoutMinutes()) * time.Minute
171 }176 }
172- timer := time.AfterFunc(time.Duration(minutes)*time.Minute, func() { j.cancel.Cancel(CancelTimeout) })177+ timer := time.AfterFunc(limit, func() { j.cancel.Cancel(CancelTimeout) })
173 defer timer.Stop()178 defer timer.Stop()
174 179
175 // ---- Set up job (JobExtension.InitializeJob) ----180 // ---- Set up job (JobExtension.InitializeJob) ----
M internal/executor/job_test.go +17 -0
@@ -601,3 +601,20 @@ func TestCancelSignalsTheShellNotTheGroup(t *testing.T) {
601601 t.Fatalf("the foreground child was signalled with the shell: %v", err)
602602 }
603603 }
604+
605+// Deadline is what is left of the timeout when the host sent the job: the
606+// executor must not grant the whole timeout again.
607+func TestDeadlineOverridesTheTimeout(t *testing.T) {
608+ needTools(t, "bash")
609+ start := time.Now()
610+ rec, res := runLocal(t, wfHead+" steps:\n - run: sleep 30\n", "j", func(c *Config) {
611+ c.TimeoutMinutes = 60
612+ c.Deadline = 500 * time.Millisecond
613+ })
614+ if res.Conclusion != ConclusionTimedOut || !strings.Contains(res.Error, "60 minutes") {
615+ t.Fatalf("%+v", res)
616+ }
617+ if time.Since(start) > 10*time.Second || rec.steps[1].Conclusion != ConclusionCancelled {
618+ t.Fatalf("took %v: %+v", time.Since(start), rec.steps[1])
619+ }
620+}
@@ -601,3 +601,20 @@ func TestCancelSignalsTheShellNotTheGroup(t *testing.T) {
601 t.Fatalf("the foreground child was signalled with the shell: %v", err)601 t.Fatalf("the foreground child was signalled with the shell: %v", err)
602 }602 }
603 }603 }
604+
605+// Deadline is what is left of the timeout when the host sent the job: the
606+// executor must not grant the whole timeout again.
607+func TestDeadlineOverridesTheTimeout(t *testing.T) {
608+ needTools(t, "bash")
609+ start := time.Now()
610+ rec, res := runLocal(t, wfHead+" steps:\n - run: sleep 30\n", "j", func(c *Config) {
611+ c.TimeoutMinutes = 60
612+ c.Deadline = 500 * time.Millisecond
613+ })
614+ if res.Conclusion != ConclusionTimedOut || !strings.Contains(res.Error, "60 minutes") {
615+ t.Fatalf("%+v", res)
616+ }
617+ if time.Since(start) > 10*time.Second || rec.steps[1].Conclusion != ConclusionCancelled {
618+ t.Fatalf("took %v: %+v", time.Since(start), rec.steps[1])
619+ }
620+}
M internal/guest/jobexec.go +4 -4
@@ -179,10 +179,10 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
179179 res := executor.Run(canceller, executor.Config{
180180 WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(),
181181 ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(),
182- TimeoutMinutes: int(spec.GetTimeoutMinutes()),
183- Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
184- RunnerVersion: spec.GetRunnerVersion(),
185- Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
182+ TimeoutMinutes: int(spec.GetTimeoutMinutes()), Deadline: time.Duration(spec.GetDeadlineMs()) * time.Millisecond,
183+ Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
184+ RunnerVersion: spec.GetRunnerVersion(),
185+ Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
186186 Debug: spec.GetDebug(), Reporter: rep,
187187 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
188188 })
@@ -179,10 +179,10 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
179 res := executor.Run(canceller, executor.Config{179 res := executor.Run(canceller, executor.Config{
180 WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(),180 WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(),
181 ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(),181 ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(),
182- TimeoutMinutes: int(spec.GetTimeoutMinutes()),182+ TimeoutMinutes: int(spec.GetTimeoutMinutes()), Deadline: time.Duration(spec.GetDeadlineMs()) * time.Millisecond,
183- Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},183+ Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
184- RunnerVersion: spec.GetRunnerVersion(),184+ RunnerVersion: spec.GetRunnerVersion(),
185- Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,185+ Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
186 Debug: spec.GetDebug(), Reporter: rep,186 Debug: spec.GetDebug(), Reporter: rep,
187 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,187 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
188 })188 })
M internal/host/job.go +62 -9
@@ -47,10 +47,15 @@ type jobRun struct {
4747 ctx context.Context
4848 cancel context.CancelCauseFunc
4949
50- cancelCh chan *execpb.Cancel // the first value is sent to the executor
51- cancelMu sync.Mutex
52- cancelAt string // reason of the first cancel request, "" if none
53- stopping bool // the runner is shutting down
50+ // receivedAt is when the host got the job: the job timeout counts from it
51+ // (runner.proto: Job.timeout_minutes).
52+ receivedAt time.Time
53+
54+ cancelCh chan *execpb.Cancel // the first value is sent to the executor
55+ cancelMu sync.Mutex
56+ cancelAt string // reason of the first cancel request, "" if none
57+ cancelKind execpb.CancelKind // kind of the first cancel request
58+ stopping bool // the runner is shutting down
5459
5560 mu sync.Mutex
5661 steps map[int32]*ermite.StepUpdate
@@ -84,9 +89,17 @@ func (j *jobRun) requestCancel(reason string, kind execpb.CancelKind) {
8489 return
8590 }
8691 j.cancelAt = reason
92+ j.cancelKind = kind
8793 j.cancelCh <- &execpb.Cancel{Reason: reason, Kind: kind}
8894 }
8995
96+// firstCancelKind is the kind of the first cancel request.
97+func (j *jobRun) firstCancelKind() execpb.CancelKind {
98+ j.cancelMu.Lock()
99+ defer j.cancelMu.Unlock()
100+ return j.cancelKind
101+}
102+
90103 func (j *jobRun) cancelRequested() bool {
91104 j.cancelMu.Lock()
92105 defer j.cancelMu.Unlock()
@@ -304,8 +317,14 @@ func (j *jobRun) run(rt *Runner, shutdown <-chan struct{}) {
304317 j.outbox = newOutbox(j.client, j.job.JobId, j.t, func(err error) { j.abandon(err) }, j.logf)
305318 j.outbox.start(ctx)
306319
307- // A shutdown is a cancellation of kind shutdown, then a hard stop.
320+ // Two reasons to stop the job from the host, each a cancellation followed
321+ // by a hard stop of the sandbox if the executor does not answer: a shutdown
322+ // of the runner (ShutdownGrace), and the job timeout, which the executor
323+ // enforces itself but a stuck executor would not (TimeoutGrace after the
324+ // deadline: the escalation SIGINT, SIGTERM, SIGKILL takes 10 s).
308325 stopHard := make(chan struct{})
326+ var hardOnce sync.Once
327+ hard := func() { hardOnce.Do(func() { close(stopHard) }) }
309328 go func() {
310329 select {
311330 case <-shutdown:
@@ -318,7 +337,21 @@ func (j *jobRun) run(rt *Runner, shutdown <-chan struct{}) {
318337 j.requestCancel("the runner is shutting down", execpb.CancelKind_CANCEL_KIND_SHUTDOWN)
319338 select {
320339 case <-time.After(j.t.ShutdownGrace):
321- close(stopHard)
340+ hard()
341+ case <-ctx.Done():
342+ }
343+ }()
344+ go func() {
345+ select {
346+ case <-time.After(time.Until(j.deadline())):
347+ case <-ctx.Done():
348+ return
349+ }
350+ j.logf("the job timeout of %d minutes has passed", j.timeoutMinutes())
351+ j.requestCancel("the job timeout has passed", execpb.CancelKind_CANCEL_KIND_TIMEOUT)
352+ select {
353+ case <-time.After(j.t.TimeoutGrace):
354+ hard()
322355 case <-ctx.Done():
323356 }
324357 }()
@@ -403,7 +436,8 @@ func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct
403436
404437 run := &execpb.Run{Job: &execpb.JobSpec{
405438 WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey,
406- Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: j.job.TimeoutMinutes,
439+ Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()),
440+ DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()),
407441 RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: "self-hosted",
408442 }}
409443 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{
@@ -428,8 +462,13 @@ func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct
428462 }
429463 return c, res.Outputs, res.RunnerShutdown && c == ermite.CiConclusion_CI_CONCLUSION_CANCELLED
430464 case j.cancelRequested() || ctx.Err() != nil:
431- // The executor did not report: it was stopped.
432- if j.isStopping() {
465+ // The executor did not report: it was stopped. A timeout wins over a
466+ // shutdown or a user cancel that came later, as on the server (the first
467+ // reason decides, and the timeout is the first when it fired first).
468+ switch {
469+ case j.firstCancelKind() == execpb.CancelKind_CANCEL_KIND_TIMEOUT:
470+ return ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT, nil, false
471+ case j.isStopping():
433472 return ermite.CiConclusion_CI_CONCLUSION_CANCELLED, nil, true
434473 }
435474 return ermite.CiConclusion_CI_CONCLUSION_CANCELLED, nil, false
@@ -444,6 +483,20 @@ func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct
444483 return ermite.CiConclusion_CI_CONCLUSION_FAILURE, nil, false
445484 }
446485
486+// timeoutMinutes is the job timeout: at least 1, 360 when the job has none
487+// (runner.proto: Job.timeout_minutes).
488+func (j *jobRun) timeoutMinutes() int {
489+ if m := int(j.job.TimeoutMinutes); m > 0 {
490+ return m
491+ }
492+ return 360
493+}
494+
495+// deadline is when the job times out, counted from its receipt.
496+func (j *jobRun) deadline() time.Time {
497+ return j.receivedAt.Add(time.Duration(j.timeoutMinutes()) * time.Minute)
498+}
499+
447500 func (j *jobRun) isStopping() bool {
448501 j.cancelMu.Lock()
449502 defer j.cancelMu.Unlock()
@@ -47,10 +47,15 @@ type jobRun struct {
47 ctx context.Context47 ctx context.Context
48 cancel context.CancelCauseFunc48 cancel context.CancelCauseFunc
49 49
50- cancelCh chan *execpb.Cancel // the first value is sent to the executor50+ // receivedAt is when the host got the job: the job timeout counts from it
51- cancelMu sync.Mutex51+ // (runner.proto: Job.timeout_minutes).
52- cancelAt string // reason of the first cancel request, "" if none52+ receivedAt time.Time
53- stopping bool // the runner is shutting down53+
54+ cancelCh chan *execpb.Cancel // the first value is sent to the executor
55+ cancelMu sync.Mutex
56+ cancelAt string // reason of the first cancel request, "" if none
57+ cancelKind execpb.CancelKind // kind of the first cancel request
58+ stopping bool // the runner is shutting down
54 59
55 mu sync.Mutex60 mu sync.Mutex
56 steps map[int32]*ermite.StepUpdate61 steps map[int32]*ermite.StepUpdate
@@ -84,9 +89,17 @@ func (j *jobRun) requestCancel(reason string, kind execpb.CancelKind) {
84 return89 return
85 }90 }
86 j.cancelAt = reason91 j.cancelAt = reason
92+ j.cancelKind = kind
87 j.cancelCh <- &execpb.Cancel{Reason: reason, Kind: kind}93 j.cancelCh <- &execpb.Cancel{Reason: reason, Kind: kind}
88 }94 }
89 95
96+// firstCancelKind is the kind of the first cancel request.
97+func (j *jobRun) firstCancelKind() execpb.CancelKind {
98+ j.cancelMu.Lock()
99+ defer j.cancelMu.Unlock()
100+ return j.cancelKind
101+}
102+
90 func (j *jobRun) cancelRequested() bool {103 func (j *jobRun) cancelRequested() bool {
91 j.cancelMu.Lock()104 j.cancelMu.Lock()
92 defer j.cancelMu.Unlock()105 defer j.cancelMu.Unlock()
@@ -304,8 +317,14 @@ func (j *jobRun) run(rt *Runner, shutdown <-chan struct{}) {
304 j.outbox = newOutbox(j.client, j.job.JobId, j.t, func(err error) { j.abandon(err) }, j.logf)317 j.outbox = newOutbox(j.client, j.job.JobId, j.t, func(err error) { j.abandon(err) }, j.logf)
305 j.outbox.start(ctx)318 j.outbox.start(ctx)
306 319
307- // A shutdown is a cancellation of kind shutdown, then a hard stop.320+ // Two reasons to stop the job from the host, each a cancellation followed
321+ // by a hard stop of the sandbox if the executor does not answer: a shutdown
322+ // of the runner (ShutdownGrace), and the job timeout, which the executor
323+ // enforces itself but a stuck executor would not (TimeoutGrace after the
324+ // deadline: the escalation SIGINT, SIGTERM, SIGKILL takes 10 s).
308 stopHard := make(chan struct{})325 stopHard := make(chan struct{})
326+ var hardOnce sync.Once
327+ hard := func() { hardOnce.Do(func() { close(stopHard) }) }
309 go func() {328 go func() {
310 select {329 select {
311 case <-shutdown:330 case <-shutdown:
@@ -318,7 +337,21 @@ func (j *jobRun) run(rt *Runner, shutdown <-chan struct{}) {
318 j.requestCancel("the runner is shutting down", execpb.CancelKind_CANCEL_KIND_SHUTDOWN)337 j.requestCancel("the runner is shutting down", execpb.CancelKind_CANCEL_KIND_SHUTDOWN)
319 select {338 select {
320 case <-time.After(j.t.ShutdownGrace):339 case <-time.After(j.t.ShutdownGrace):
321- close(stopHard)340+ hard()
341+ case <-ctx.Done():
342+ }
343+ }()
344+ go func() {
345+ select {
346+ case <-time.After(time.Until(j.deadline())):
347+ case <-ctx.Done():
348+ return
349+ }
350+ j.logf("the job timeout of %d minutes has passed", j.timeoutMinutes())
351+ j.requestCancel("the job timeout has passed", execpb.CancelKind_CANCEL_KIND_TIMEOUT)
352+ select {
353+ case <-time.After(j.t.TimeoutGrace):
354+ hard()
322 case <-ctx.Done():355 case <-ctx.Done():
323 }356 }
324 }()357 }()
@@ -403,7 +436,8 @@ func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct
403 436
404 run := &execpb.Run{Job: &execpb.JobSpec{437 run := &execpb.Run{Job: &execpb.JobSpec{
405 WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey,438 WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey,
406- Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: j.job.TimeoutMinutes,439+ Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()),
440+ DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()),
407 RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: "self-hosted",441 RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: "self-hosted",
408 }}442 }}
409 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{443 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{
@@ -428,8 +462,13 @@ func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct
428 }462 }
429 return c, res.Outputs, res.RunnerShutdown && c == ermite.CiConclusion_CI_CONCLUSION_CANCELLED463 return c, res.Outputs, res.RunnerShutdown && c == ermite.CiConclusion_CI_CONCLUSION_CANCELLED
430 case j.cancelRequested() || ctx.Err() != nil:464 case j.cancelRequested() || ctx.Err() != nil:
431- // The executor did not report: it was stopped.465+ // The executor did not report: it was stopped. A timeout wins over a
432- if j.isStopping() {466+ // shutdown or a user cancel that came later, as on the server (the first
467+ // reason decides, and the timeout is the first when it fired first).
468+ switch {
469+ case j.firstCancelKind() == execpb.CancelKind_CANCEL_KIND_TIMEOUT:
470+ return ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT, nil, false
471+ case j.isStopping():
433 return ermite.CiConclusion_CI_CONCLUSION_CANCELLED, nil, true472 return ermite.CiConclusion_CI_CONCLUSION_CANCELLED, nil, true
434 }473 }
435 return ermite.CiConclusion_CI_CONCLUSION_CANCELLED, nil, false474 return ermite.CiConclusion_CI_CONCLUSION_CANCELLED, nil, false
@@ -444,6 +483,20 @@ func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct
444 return ermite.CiConclusion_CI_CONCLUSION_FAILURE, nil, false483 return ermite.CiConclusion_CI_CONCLUSION_FAILURE, nil, false
445 }484 }
446 485
486+// timeoutMinutes is the job timeout: at least 1, 360 when the job has none
487+// (runner.proto: Job.timeout_minutes).
488+func (j *jobRun) timeoutMinutes() int {
489+ if m := int(j.job.TimeoutMinutes); m > 0 {
490+ return m
491+ }
492+ return 360
493+}
494+
495+// deadline is when the job times out, counted from its receipt.
496+func (j *jobRun) deadline() time.Time {
497+ return j.receivedAt.Add(time.Duration(j.timeoutMinutes()) * time.Minute)
498+}
499+
447 func (j *jobRun) isStopping() bool {500 func (j *jobRun) isStopping() bool {
448 j.cancelMu.Lock()501 j.cancelMu.Lock()
449 defer j.cancelMu.Unlock()502 defer j.cancelMu.Unlock()
M internal/host/rpc.go +5 -0
@@ -111,6 +111,10 @@ type Timing struct {
111111 LogFlush time.Duration
112112 // WatchRetry is the delay before WatchJob is reopened.
113113 WatchRetry time.Duration
114+ // TimeoutGrace is how long the executor gets after the job timeout to stop
115+ // its steps (SIGINT, then SIGTERM after 7.5 s, then SIGKILL 2.5 s later) and
116+ // report, before the sandbox is destroyed.
117+ TimeoutGrace time.Duration
114118 // ShutdownGrace is how long jobs get to stop after a shutdown signal before
115119 // their sandbox is destroyed.
116120 ShutdownGrace time.Duration
@@ -138,6 +142,7 @@ func (t Timing) withDefaults() Timing {
138142 def(&t.BackoffMax, 30*time.Second)
139143 def(&t.LogFlush, 250*time.Millisecond)
140144 def(&t.WatchRetry, time.Second)
145+ def(&t.TimeoutGrace, 30*time.Second)
141146 def(&t.ShutdownGrace, 30*time.Second)
142147 def(&t.CompleteDeadline, 5*time.Minute)
143148 def(&t.NoJobPause, 0)
@@ -111,6 +111,10 @@ type Timing struct {
111 LogFlush time.Duration111 LogFlush time.Duration
112 // WatchRetry is the delay before WatchJob is reopened.112 // WatchRetry is the delay before WatchJob is reopened.
113 WatchRetry time.Duration113 WatchRetry time.Duration
114+ // TimeoutGrace is how long the executor gets after the job timeout to stop
115+ // its steps (SIGINT, then SIGTERM after 7.5 s, then SIGKILL 2.5 s later) and
116+ // report, before the sandbox is destroyed.
117+ TimeoutGrace time.Duration
114 // ShutdownGrace is how long jobs get to stop after a shutdown signal before118 // ShutdownGrace is how long jobs get to stop after a shutdown signal before
115 // their sandbox is destroyed.119 // their sandbox is destroyed.
116 ShutdownGrace time.Duration120 ShutdownGrace time.Duration
@@ -138,6 +142,7 @@ func (t Timing) withDefaults() Timing {
138 def(&t.BackoffMax, 30*time.Second)142 def(&t.BackoffMax, 30*time.Second)
139 def(&t.LogFlush, 250*time.Millisecond)143 def(&t.LogFlush, 250*time.Millisecond)
140 def(&t.WatchRetry, time.Second)144 def(&t.WatchRetry, time.Second)
145+ def(&t.TimeoutGrace, 30*time.Second)
141 def(&t.ShutdownGrace, 30*time.Second)146 def(&t.ShutdownGrace, 30*time.Second)
142 def(&t.CompleteDeadline, 5*time.Minute)147 def(&t.CompleteDeadline, 5*time.Minute)
143 def(&t.NoJobPause, 0)148 def(&t.NoJobPause, 0)
M internal/host/runner.go +12 -2
@@ -35,10 +35,20 @@ type Runner struct {
3535 // JobSpec sizes the sandbox of a job; the zero value takes the defaults.
3636 JobSpec func(*ermite.Job) sandbox.JobSpec
3737
38+ // now returns the current time; tests replace it.
39+ now func() time.Time
40+
3841 leaked atomic.Bool
3942 active sync.WaitGroup
4043 }
4144
45+func (r *Runner) clock() time.Time {
46+ if r.now != nil {
47+ return r.now()
48+ }
49+ return time.Now()
50+}
51+
4252 func (r *Runner) logf(format string, a ...any) {
4353 if r.Logf != nil {
4454 r.Logf(format, a...)
@@ -175,8 +185,8 @@ func (r *Runner) startJob(ctx context.Context, job *ermite.Job, t Timing, shutdo
175185 jctx, cancel := context.WithCancelCause(context.WithoutCancel(ctx))
176186 jr := &jobRun{
177187 r: r, job: job, client: r.Job(job.JobToken), t: t, ctx: jctx, cancel: cancel,
178- logf: func(f string, a ...any) { r.logf("job "+job.JobId+": "+f, a...) },
179- cancelCh: make(chan *execpb.Cancel, 1), steps: map[int32]*ermite.StepUpdate{},
188+ logf: func(f string, a ...any) { r.logf("job "+job.JobId+": "+f, a...) },
189+ receivedAt: r.clock(), cancelCh: make(chan *execpb.Cancel, 1), steps: map[int32]*ermite.StepUpdate{},
180190 }
181191 r.logf("job %s (run %d, attempt %d, %s) assigned", job.JobId, job.RunId, job.Attempt, job.JobKey)
182192 r.active.Add(1)
@@ -35,10 +35,20 @@ type Runner struct {
35 // JobSpec sizes the sandbox of a job; the zero value takes the defaults.35 // JobSpec sizes the sandbox of a job; the zero value takes the defaults.
36 JobSpec func(*ermite.Job) sandbox.JobSpec36 JobSpec func(*ermite.Job) sandbox.JobSpec
37 37
38+ // now returns the current time; tests replace it.
39+ now func() time.Time
40+
38 leaked atomic.Bool41 leaked atomic.Bool
39 active sync.WaitGroup42 active sync.WaitGroup
40 }43 }
41 44
45+func (r *Runner) clock() time.Time {
46+ if r.now != nil {
47+ return r.now()
48+ }
49+ return time.Now()
50+}
51+
42 func (r *Runner) logf(format string, a ...any) {52 func (r *Runner) logf(format string, a ...any) {
43 if r.Logf != nil {53 if r.Logf != nil {
44 r.Logf(format, a...)54 r.Logf(format, a...)
@@ -175,8 +185,8 @@ func (r *Runner) startJob(ctx context.Context, job *ermite.Job, t Timing, shutdo
175 jctx, cancel := context.WithCancelCause(context.WithoutCancel(ctx))185 jctx, cancel := context.WithCancelCause(context.WithoutCancel(ctx))
176 jr := &jobRun{186 jr := &jobRun{
177 r: r, job: job, client: r.Job(job.JobToken), t: t, ctx: jctx, cancel: cancel,187 r: r, job: job, client: r.Job(job.JobToken), t: t, ctx: jctx, cancel: cancel,
178- logf: func(f string, a ...any) { r.logf("job "+job.JobId+": "+f, a...) },188+ logf: func(f string, a ...any) { r.logf("job "+job.JobId+": "+f, a...) },
179- cancelCh: make(chan *execpb.Cancel, 1), steps: map[int32]*ermite.StepUpdate{},189+ receivedAt: r.clock(), cancelCh: make(chan *execpb.Cancel, 1), steps: map[int32]*ermite.StepUpdate{},
180 }190 }
181 r.logf("job %s (run %d, attempt %d, %s) assigned", job.JobId, job.RunId, job.Attempt, job.JobKey)191 r.logf("job %s (run %d, attempt %d, %s) assigned", job.JobId, job.RunId, job.Attempt, job.JobKey)
182 r.active.Add(1)192 r.active.Add(1)
M internal/host/runner_test.go +57 -0
@@ -790,3 +790,60 @@ func TestLoadConfig(t *testing.T) {
790790 }
791791
792792 var _ = ermiteconnect.RunnerServiceClient(nil)
793+
794+// The job timeout counts from the receipt of the job, and the host enforces it
795+// even when the executor does not: the sandbox is stopped and the job ends
796+// timed_out.
797+func TestHostEnforcesTheJobTimeout(t *testing.T) {
798+ h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, cancels <-chan *execpb.Cancel) {
799+ stepStart(c, 1, "Set up job")
800+ logLineFrame(c, 1, "stuck", false)
801+ cn := <-cancels
802+ if cn.Kind != execpb.CancelKind_CANCEL_KIND_TIMEOUT {
803+ t.Errorf("kind %v", cn.Kind)
804+ }
805+ select {} // the executor ignores the cancel
806+ })
807+ h.runner.Timing.TimeoutGrace = 200 * time.Millisecond
808+ job := testJob("j1")
809+ job.TimeoutMinutes = 1
810+ h.runner.Job = func(tok string) ermiteconnect.RunnerServiceClient {
811+ return NewClient(h.url, tok, http.DefaultTransport)
812+ }
813+ h.runner.now = func() time.Time { return time.Now().Add(-59*time.Second - 700*time.Millisecond) } // received 59.7 s ago
814+ h.srv.jobs = []*ermite.Job{job}
815+ start := time.Now()
816+ h.run(t)
817+ eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
818+ if d := time.Since(start); d > 5*time.Second {
819+ t.Fatalf("took %v", d)
820+ }
821+ h.srv.mu.Lock()
822+ defer h.srv.mu.Unlock()
823+ c := h.srv.completes[0]
824+ if c.Conclusion != ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT || c.RunnerShutdown {
825+ t.Fatalf("%v", c.Conclusion)
826+ }
827+ for _, u := range c.Steps {
828+ if u.State != ermite.StepState_STEP_STATE_COMPLETED {
829+ t.Errorf("step %d not completed", u.Number)
830+ }
831+ }
832+}
833+
834+// The remaining time, not the whole timeout, goes to the executor.
835+func TestRunCarriesTheRemainingDeadline(t *testing.T) {
836+ var spec *execpb.JobSpec
837+ inner := simpleScript("x")
838+ h := newHarness(t, func(t *testing.T, c *execstream.Conn, run *execpb.Run, cn <-chan *execpb.Cancel) {
839+ spec = run.Job
840+ inner(t, c, run, cn)
841+ })
842+ h.runner.now = func() time.Time { return time.Now().Add(-3 * time.Minute) }
843+ h.srv.jobs = []*ermite.Job{testJob("j1")} // 5 minutes
844+ h.run(t)
845+ eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
846+ if spec == nil || spec.TimeoutMinutes != 5 || spec.DeadlineMs < 110_000 || spec.DeadlineMs > 120_000 {
847+ t.Fatalf("%v", spec)
848+ }
849+}
@@ -790,3 +790,60 @@ func TestLoadConfig(t *testing.T) {
790 }790 }
791 791
792 var _ = ermiteconnect.RunnerServiceClient(nil)792 var _ = ermiteconnect.RunnerServiceClient(nil)
793+
794+// The job timeout counts from the receipt of the job, and the host enforces it
795+// even when the executor does not: the sandbox is stopped and the job ends
796+// timed_out.
797+func TestHostEnforcesTheJobTimeout(t *testing.T) {
798+ h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, cancels <-chan *execpb.Cancel) {
799+ stepStart(c, 1, "Set up job")
800+ logLineFrame(c, 1, "stuck", false)
801+ cn := <-cancels
802+ if cn.Kind != execpb.CancelKind_CANCEL_KIND_TIMEOUT {
803+ t.Errorf("kind %v", cn.Kind)
804+ }
805+ select {} // the executor ignores the cancel
806+ })
807+ h.runner.Timing.TimeoutGrace = 200 * time.Millisecond
808+ job := testJob("j1")
809+ job.TimeoutMinutes = 1
810+ h.runner.Job = func(tok string) ermiteconnect.RunnerServiceClient {
811+ return NewClient(h.url, tok, http.DefaultTransport)
812+ }
813+ h.runner.now = func() time.Time { return time.Now().Add(-59*time.Second - 700*time.Millisecond) } // received 59.7 s ago
814+ h.srv.jobs = []*ermite.Job{job}
815+ start := time.Now()
816+ h.run(t)
817+ eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
818+ if d := time.Since(start); d > 5*time.Second {
819+ t.Fatalf("took %v", d)
820+ }
821+ h.srv.mu.Lock()
822+ defer h.srv.mu.Unlock()
823+ c := h.srv.completes[0]
824+ if c.Conclusion != ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT || c.RunnerShutdown {
825+ t.Fatalf("%v", c.Conclusion)
826+ }
827+ for _, u := range c.Steps {
828+ if u.State != ermite.StepState_STEP_STATE_COMPLETED {
829+ t.Errorf("step %d not completed", u.Number)
830+ }
831+ }
832+}
833+
834+// The remaining time, not the whole timeout, goes to the executor.
835+func TestRunCarriesTheRemainingDeadline(t *testing.T) {
836+ var spec *execpb.JobSpec
837+ inner := simpleScript("x")
838+ h := newHarness(t, func(t *testing.T, c *execstream.Conn, run *execpb.Run, cn <-chan *execpb.Cancel) {
839+ spec = run.Job
840+ inner(t, c, run, cn)
841+ })
842+ h.runner.now = func() time.Time { return time.Now().Add(-3 * time.Minute) }
843+ h.srv.jobs = []*ermite.Job{testJob("j1")} // 5 minutes
844+ h.run(t)
845+ eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
846+ if spec == nil || spec.TimeoutMinutes != 5 || spec.DeadlineMs < 110_000 || spec.DeadlineMs > 120_000 {
847+ t.Fatalf("%v", spec)
848+ }
849+}

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 11

↑↓ moveenter openesc close