1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6 "context"
7 "sync"
8 "testing"
9 "time"
10
11 "connectrpc.com/connect"
12 "github.com/bnema/zerowrap"
13
14 ermite "bnema.dev/ermite-api/gen/ermite"
15 "bnema.dev/ermite-runner/internal/execstream/execpb"
16)
17
18// stubClient answers the calls of the final report. CompleteJob records the
19// deadline of its context, then waits for the context to end.
20type stubClient struct {
21 mu sync.Mutex
22 deadline time.Time
23 hang bool // AppendLog waits for its context to end
24}
25
26func (c *stubClient) UpdateJob(context.Context, *connect.Request[ermite.UpdateJobRequest]) (*connect.Response[ermite.UpdateJobResponse], error) {
27 return connect.NewResponse(&ermite.UpdateJobResponse{}), nil
28}
29
30func (c *stubClient) AppendLog(ctx context.Context, r *connect.Request[ermite.AppendLogRequest]) (*connect.Response[ermite.AppendLogResponse], error) {
31 c.mu.Lock()
32 hang := c.hang
33 c.mu.Unlock()
34 if hang {
35 <-ctx.Done()
36 return nil, ctx.Err()
37 }
38 return connect.NewResponse(&ermite.AppendLogResponse{NextOffset: r.Msg.Offset + int64(len(r.Msg.Lines))}), nil
39}
40
41func (c *stubClient) Heartbeat(context.Context, *connect.Request[ermite.HeartbeatRequest]) (*connect.Response[ermite.HeartbeatResponse], error) {
42 return connect.NewResponse(&ermite.HeartbeatResponse{}), nil
43}
44
45func (c *stubClient) WatchJob(context.Context, *connect.Request[ermite.WatchJobRequest]) (*connect.ServerStreamForClient[ermite.WatchJobResponse], error) {
46 return nil, connect.NewError(connect.CodeUnimplemented, nil)
47}
48
49func (c *stubClient) CompleteJob(ctx context.Context, _ *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) {
50 c.mu.Lock()
51 c.deadline, _ = ctx.Deadline()
52 c.mu.Unlock()
53 return connect.NewResponse(&ermite.CompleteJobResponse{}), nil
54}
55
56// The report of a job gets ShutdownReport once the runner's shutdown channel is
57// closed, even when the stop timers did not mark the job as stopping (GO-004),
58// and CompleteDeadline otherwise.
59func TestReportBudgetFollowsTheShutdownChannel(t *testing.T) {
60 for _, tc := range []struct {
61 name string
62 shutdown bool
63 want time.Duration
64 }{
65 {"running", false, time.Hour},
66 {"shutdown", true, 30 * time.Second},
67 } {
68 t.Run(tc.name, func(t *testing.T) {
69 tm := Timing{
70 CallTimeout: 24 * time.Hour, CompleteDeadline: time.Hour, ShutdownReport: 30 * time.Second,
71 BackoffMin: time.Millisecond, BackoffMax: time.Millisecond,
72 }
73 cl := &stubClient{}
74 ctx, cancel := context.WithCancel(context.Background())
75 defer cancel()
76 j := &jobRun{client: cl, t: tm, ctx: ctx, job: &ermite.Job{JobId: "j1"}, steps: map[int32]*ermite.StepUpdate{},
77 cancelCh: make(chan *execpb.Cancel, 1), log: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logBuf{}})}
78 j.outbox = newOutbox(cl, "j1", tm, func(error) {}, j.log)
79 j.outbox.start(ctx)
80 shutdown := make(chan struct{})
81 if tc.shutdown {
82 close(shutdown)
83 }
84 if j.isStopping() {
85 t.Fatal("the job must not be marked as stopping: only the channel is closed")
86 }
87 start := time.Now()
88 j.report(ctx, outcome{conclusion: ermite.CiConclusion_CI_CONCLUSION_SUCCESS}, shutdown)
89 cl.mu.Lock()
90 defer cl.mu.Unlock()
91 if cl.deadline.IsZero() {
92 t.Fatal("CompleteJob was not called")
93 }
94 if got := cl.deadline.Sub(start); got > tc.want+time.Second || got < tc.want-time.Second {
95 t.Fatalf("budget %v, want about %v", got, tc.want)
96 }
97 })
98 }
99}