返回 DeepSeek-Reasonix
jobs_test.go
根目录 / internal / jobs / jobs_test.go
1 package jobs
2
3 import (
4 "context"
5 "io"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 "reasonix/internal/event"
12 "reasonix/internal/evidence"
13 )
14
15 type recordingSink struct {
16 mu sync.Mutex
17 events []event.Event
18 }
19
20 func (s *recordingSink) Emit(ev event.Event) {
21 s.mu.Lock()
22 defer s.mu.Unlock()
23 s.events = append(s.events, ev)
24 }
25
26 func (s *recordingSink) texts() []string {
27 s.mu.Lock()
28 defer s.mu.Unlock()
29 out := make([]string, 0, len(s.events))
30 for _, ev := range s.events {
31 out = append(out, ev.Text)
32 }
33 return out
34 }
35
36 type blockingFinishedSink struct {
37 mu sync.Mutex
38 events []event.Event
39 entered chan struct{}
40 released chan struct{}
41 once sync.Once
42 }
43
44 func (s *blockingFinishedSink) Emit(ev event.Event) {
45 if strings.Contains(ev.Text, "background bash finished") {
46 s.once.Do(func() { close(s.entered) })
47 <-s.released
48 }
49 s.mu.Lock()
50 defer s.mu.Unlock()
51 s.events = append(s.events, ev)
52 }
53
54 func waitFor(t *testing.T, cond func() bool) {
55 t.Helper()
56 deadline := time.Now().Add(2 * time.Second)
57 for time.Now().Before(deadline) {
58 if cond() {
59 return
60 }
61 time.Sleep(5 * time.Millisecond)
62 }
63 t.Fatal("condition not met within deadline")
64 }
65
66 func TestStartForSessionStampsJobContext(t *testing.T) {
67 m := NewManager(event.Discard)
68 defer m.Close()
69 seen := make(chan string, 1)
70 j := m.StartForSession("session-a", "task", "scoped", func(ctx context.Context, _ io.Writer) (string, error) {
71 seen <- SessionFromContext(ctx)
72 return "done", nil
73 })
74 if got := <-seen; got != "session-a" {
75 t.Fatalf("job context session = %q, want session-a", got)
76 }
77 if res := m.WaitForSession(context.Background(), "session-a", []string{j.ID}, 5); len(res) != 1 || res[0].Status != Done {
78 t.Fatalf("job result = %+v, want done", res)
79 }
80 }
81
82 func TestJobStartObserverSeesLifetimeUntilTerminal(t *testing.T) {
83 observed := make(chan (<-chan struct{}), 1)
84 release := make(chan struct{})
85 m := NewManager(event.Discard, WithJobStartObserver(func(done <-chan struct{}) {
86 observed <- done
87 }))
88 t.Cleanup(m.Close)
89 job := m.StartForSession("session-a", "bash", "lifetime", func(context.Context, io.Writer) (string, error) {
90 <-release
91 return "", nil
92 })
93 var lifetime <-chan struct{}
94 select {
95 case lifetime = <-observed:
96 case <-time.After(time.Second):
97 t.Fatal("job start observer was not called")
98 }
99 select {
100 case <-lifetime:
101 t.Fatal("job lifetime closed before run completed")
102 default:
103 }
104 close(release)
105 if results := m.WaitForSession(context.Background(), "session-a", []string{job.ID}, 2); len(results) != 1 || results[0].Status != Done {
106 t.Fatalf("wait results = %+v", results)
107 }
108 select {
109 case <-lifetime:
110 case <-time.After(time.Second):
111 t.Fatal("job lifetime did not close at terminal status")
112 }
113 }
114
115 func TestReserveStartForSessionIsAtomic(t *testing.T) {
116 m := NewManager(event.Discard)
117 defer m.Close()
118
119 const callers = 16
120 start := make(chan struct{})
121 releases := make(chan func(), callers)
122 results := make(chan bool, callers)
123 for i := 0; i < callers; i++ {
124 go func() {
125 <-start
126 release, _, ok := m.ReserveStartForSession("session-a", "task", 3)
127 if ok {
128 releases <- release
129 }
130 results <- ok
131 }()
132 }
133 close(start)
134 reserved := 0
135 for i := 0; i < callers; i++ {
136 if <-results {
137 reserved++
138 }
139 }
140 if reserved != 3 {
141 t.Fatalf("concurrent reservations = %d, want exactly 3", reserved)
142 }
143 for i := 0; i < reserved; i++ {
144 (<-releases)()
145 }
146 if release, running, ok := m.ReserveStartForSession("session-a", "task", 3); !ok || running != 0 {
147 t.Fatalf("reservation after release = (running=%d, ok=%v), want (0, true)", running, ok)
148 } else {
149 release()
150 }
151 }
152
153 func TestReserveStartForSessionCountsKilledJobUntilExit(t *testing.T) {
154 m := NewManager(event.Discard)
155 defer m.Close()
156 exit := make(chan struct{})
157 j := m.StartForSession("session-a", "task", "unwinding", func(context.Context, io.Writer) (string, error) {
158 <-exit
159 return "done", nil
160 })
161 if !m.KillForSession("session-a", j.ID) {
162 t.Fatal("KillForSession did not find running job")
163 }
164 if got := m.RunningForSession("session-a"); len(got) != 1 || got[0].ID != j.ID || got[0].Status != string(Running) {
165 t.Fatalf("running view while killed job unwinds = %+v, want one operationally-running job", got)
166 }
167 if release, running, ok := m.ReserveStartForSession("session-a", "task", 1); ok {
168 release()
169 t.Fatal("reserved a replacement while killed writer goroutine was still running")
170 } else if running != 1 {
171 t.Fatalf("unwinding writer count = %d, want 1", running)
172 }
173 close(exit)
174 if res := m.WaitForSession(context.Background(), "session-a", []string{j.ID}, 5); len(res) != 1 || res[0].Status != Killed {
175 t.Fatalf("killed job result = %+v", res)
176 }
177 if got := m.RunningForSession("session-a"); len(got) != 0 {
178 t.Fatalf("running view after killed job exited = %+v, want empty", got)
179 }
180 if release, running, ok := m.ReserveStartForSession("session-a", "task", 1); !ok || running != 0 {
181 t.Fatalf("reservation after exit = (running=%d, ok=%v), want (0, true)", running, ok)
182 } else {
183 release()
184 }
185 }
186
187 func TestLeaseEvidenceWaitsForKilledJobExit(t *testing.T) {
188 m := NewManager(event.Discard)
189 defer m.Close()
190 exit := make(chan struct{})
191 j := m.StartForSession("session-a", "task", "partial writer", func(ctx context.Context, _ io.Writer) (string, error) {
192 <-exit
193 PublishEvidence(ctx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
194 ToolName: "write_file", Success: true, Mutation: true, Paths: []string{"partial.go"},
195 }}})
196 return "stopped", nil
197 })
198 if !m.KillForSession("session-a", j.ID) {
199 t.Fatal("KillForSession did not find running job")
200 }
201 if early := m.LeaseEvidenceForSession("session-a", j.ID); len(early.Receipts) != 0 {
202 t.Fatalf("collected evidence before killed job exited: %+v", early)
203 }
204 close(exit)
205 if res := m.WaitForSession(context.Background(), "session-a", []string{j.ID}, 5); len(res) != 1 || res[0].Status != Killed {
206 t.Fatalf("killed job result = %+v", res)
207 }
208 if got := m.LeaseEvidenceForSession("session-a", j.ID); !got.HasMutation() {
209 t.Fatalf("partial evidence lost after killed job exit: %+v", got)
210 }
211 // Lease does not consume: a second lease still returns the receipts, and a
212 // commit is required to drain them.
213 if again := m.LeaseEvidenceForSession("session-a", j.ID); !again.HasMutation() {
214 t.Fatalf("lease consumed evidence without a commit: %+v", again)
215 }
216 m.CommitEvidenceForSession("session-a", j.ID)
217 if after := m.LeaseEvidenceForSession("session-a", j.ID); len(after.Receipts) != 0 {
218 t.Fatalf("committed evidence still leasable: %+v", after)
219 }
220 }
221
222 func TestTryLeaseEvidenceForSessionReportsReadiness(t *testing.T) {
223 m := NewManager(event.Discard)
224 defer m.Close()
225 exit := make(chan struct{})
226 j := m.StartForSession("session-a", "task", "partial writer", func(ctx context.Context, _ io.Writer) (string, error) {
227 <-exit
228 PublishEvidence(ctx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
229 ToolName: "write_file", Success: true, Mutation: true, Paths: []string{"partial.go"},
230 }}})
231 return "stopped", nil
232 })
233 if _, ready := m.TryLeaseEvidenceForSession("session-a", "no-such-job"); ready {
234 t.Fatal("unknown job reported ready")
235 }
236 if _, ready := m.TryLeaseEvidenceForSession("session-a", j.ID); ready {
237 t.Fatal("running job reported ready before it reached a terminal state")
238 }
239 if !m.KillForSession("session-a", j.ID) {
240 t.Fatal("KillForSession did not find running job")
241 }
242 // Killed flips the status synchronously but the goroutine has not exited yet
243 // (still blocked on exit): must not report ready.
244 if _, ready := m.TryLeaseEvidenceForSession("session-a", j.ID); ready {
245 t.Fatal("killed-but-unwinding job reported ready before its evidence was flushed")
246 }
247 close(exit)
248 if res := m.WaitForSession(context.Background(), "session-a", []string{j.ID}, 5); len(res) != 1 || res[0].Status != Killed {
249 t.Fatalf("killed job result = %+v", res)
250 }
251 summary, ready := m.TryLeaseEvidenceForSession("session-a", j.ID)
252 if !ready || !summary.HasMutation() {
253 t.Fatalf("ready/summary after exit = %v/%+v, want ready with the published mutation", ready, summary)
254 }
255 m.CommitEvidenceForSession("session-a", j.ID)
256 if summary, ready := m.TryLeaseEvidenceForSession("session-a", j.ID); !ready || len(summary.Receipts) != 0 {
257 t.Fatalf("post-commit ready/summary = %v/%+v, want ready with no receipts", ready, summary)
258 }
259 }
260
261 func TestPendingEvidenceJobIDsForSession(t *testing.T) {
262 m := NewManager(event.Discard)
263 defer m.Close()
264
265 running := m.StartForSession("session-a", "task", "still going", func(ctx context.Context, _ io.Writer) (string, error) {
266 <-ctx.Done()
267 return "", ctx.Err()
268 })
269 mutator := m.StartForSession("session-a", "task", "writer", func(ctx context.Context, _ io.Writer) (string, error) {
270 PublishEvidence(ctx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
271 ToolName: "write_file", Success: true, Mutation: true, Paths: []string{"changed.go"},
272 }}})
273 return "done", nil
274 })
275 readOnly := m.StartForSession("session-a", "task", "reader", func(context.Context, io.Writer) (string, error) {
276 return "no changes", nil
277 })
278 otherSession := m.StartForSession("session-b", "task", "writer", func(ctx context.Context, _ io.Writer) (string, error) {
279 PublishEvidence(ctx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
280 ToolName: "write_file", Success: true, Mutation: true, Paths: []string{"other.go"},
281 }}})
282 return "done", nil
283 })
284 if res := m.WaitForSession(context.Background(), "session-a", []string{mutator.ID, readOnly.ID}, 5); len(res) != 2 {
285 t.Fatalf("wait = %+v, want mutator and read-only jobs done", res)
286 }
287 if res := m.WaitForSession(context.Background(), "session-b", []string{otherSession.ID}, 5); len(res) != 1 {
288 t.Fatalf("wait = %+v, want other-session job done", res)
289 }
290
291 pending := m.PendingEvidenceJobIDsForSession("session-a")
292 if len(pending) != 1 || pending[0] != mutator.ID {
293 t.Fatalf("pending = %v, want only %q (running job excluded, read-only job has no receipts, other session excluded)", pending, mutator.ID)
294 }
295
296 m.CommitEvidenceForSession("session-a", mutator.ID)
297 if pending := m.PendingEvidenceJobIDsForSession("session-a"); len(pending) != 0 {
298 t.Fatalf("pending after commit = %v, want none", pending)
299 }
300 _ = running // still running when the test ends; Close() cancels and reaps it
301 }
302
303 func TestStalledWarningIgnoresReturnedJobBeforeTerminalStatusPublished(t *testing.T) {
304 sink := &blockingFinishedSink{entered: make(chan struct{}), released: make(chan struct{})}
305 m := NewManager(sink, WithStalledWarningAfter(20*time.Millisecond))
306 defer func() {
307 close(sink.released)
308 m.Close()
309 }()
310
311 j := m.Start("bash", "", func(context.Context, io.Writer) (string, error) {
312 return "", nil
313 })
314 select {
315 case <-sink.entered:
316 case <-time.After(time.Second):
317 t.Fatal("completion notice did not start")
318 }
319
320 time.Sleep(50 * time.Millisecond)
321 note := m.DrainCompletedNote()
322 if strings.Contains(note, "may be stalled") {
323 t.Fatalf("got false stalled warning for already-returned job %s: %q", j.ID, note)
324 }
325 if !strings.Contains(note, j.ID) || !strings.Contains(note, string(Done)) {
326 t.Fatalf("completion note = %q, want done update for %s", note, j.ID)
327 }
328 }
329
330 // A job runs to completion: Wait reports Done with its output, and the completion
331 // note drains exactly once.
332 func TestStartWaitDoneAndDrain(t *testing.T) {
333 m := NewManager(event.Discard)
334 defer m.Close()
335
336 j := m.Start("bash", "echo", func(_ context.Context, out io.Writer) (string, error) {
337 io.WriteString(out, "hello\n")
338 return "", nil
339 })
340 res := m.Wait(context.Background(), []string{j.ID}, 5)
341 if len(res) != 1 || res[0].Status != Done {
342 t.Fatalf("want one Done result, got %+v", res)
343 }
344 if !strings.Contains(res[0].Output, "hello") {
345 t.Errorf("output = %q, want it to contain hello", res[0].Output)
346 }
347 note := m.DrainCompletedNote()
348 if !strings.Contains(note, j.ID) {
349 t.Errorf("note = %q, want it to mention %s", note, j.ID)
350 }
351 if again := m.DrainCompletedNote(); again != "" {
352 t.Errorf("second drain = %q, want empty", again)
353 }
354 }
355
356 // Output returns only the bytes produced since the previous read.
357 func TestOutputStreamsIncrementally(t *testing.T) {
358 m := NewManager(event.Discard)
359 defer m.Close()
360
361 release := make(chan struct{})
362 j := m.Start("bash", "", func(_ context.Context, out io.Writer) (string, error) {
363 io.WriteString(out, "first\n")
364 <-release
365 io.WriteString(out, "second\n")
366 return "", nil
367 })
368
369 waitFor(t, func() bool {
370 txt, _, _ := m.Output(j.ID)
371 return strings.Contains(txt, "first")
372 })
373 close(release)
374 m.Wait(context.Background(), []string{j.ID}, 5)
375
376 txt, st, ok := m.Output(j.ID)
377 if !ok || st != Done {
378 t.Fatalf("Output after done: ok=%v status=%s", ok, st)
379 }
380 if !strings.Contains(txt, "second") || strings.Contains(txt, "first") {
381 t.Errorf("incremental output = %q, want only the new 'second' chunk", txt)
382 }
383 }
384
385 // Kill cancels a running job; a second Kill is a no-op once it has finished.
386 func TestKill(t *testing.T) {
387 m := NewManager(event.Discard)
388 defer m.Close()
389
390 j := m.Start("bash", "", func(ctx context.Context, _ io.Writer) (string, error) {
391 <-ctx.Done()
392 return "", ctx.Err()
393 })
394 if !m.Kill(j.ID) {
395 t.Fatal("Kill on a running job returned false")
396 }
397 res := m.Wait(context.Background(), []string{j.ID}, 5)
398 if len(res) != 1 || res[0].Status != Killed {
399 t.Fatalf("want Killed, got %+v", res)
400 }
401 if m.Kill(j.ID) {
402 t.Error("Kill on a finished job should return false")
403 }
404 }
405
406 func TestJobPanicRecoveredAsFailed(t *testing.T) {
407 sink := &recordingSink{}
408 m := NewManager(sink)
409 defer m.Close()
410
411 j := m.Start("task", "panic", func(context.Context, io.Writer) (string, error) {
412 panic("boom")
413 })
414 res := m.Wait(context.Background(), []string{j.ID}, 5)
415 if len(res) != 1 || res[0].Status != Failed {
416 t.Fatalf("want Failed result after panic, got %+v", res)
417 }
418 if !strings.Contains(res[0].Output, "internal error: panic: boom") {
419 t.Fatalf("panic output = %q, want internal panic message", res[0].Output)
420 }
421 waitFor(t, func() bool {
422 sink.mu.Lock()
423 defer sink.mu.Unlock()
424 for _, ev := range sink.events {
425 if strings.Contains(ev.Text, "background task failed") && strings.Contains(ev.Detail, j.ID) && strings.Contains(ev.Detail, "panic: boom") {
426 return true
427 }
428 }
429 return false
430 })
431 }
432
433 func TestStalledWarningEmitsNoticeAndDrainNote(t *testing.T) {
434 sink := &recordingSink{}
435 m := NewManager(sink, WithStalledWarningAfter(20*time.Millisecond))
436 defer m.Close()
437
438 j := m.Start("bash", "quiet", func(ctx context.Context, _ io.Writer) (string, error) {
439 <-ctx.Done()
440 return "", ctx.Err()
441 })
442 defer m.Kill(j.ID)
443
444 waitFor(t, func() bool {
445 for _, text := range sink.texts() {
446 if strings.Contains(text, "may be stalled") && strings.Contains(text, j.ID) {
447 return true
448 }
449 }
450 return false
451 })
452 if _, st, ok := m.Output(j.ID); !ok || st != Running {
453 t.Fatalf("stalled job output status = %q ok=%v, want running", st, ok)
454 }
455 note := m.DrainCompletedNote()
456 if !strings.Contains(note, "may be stalled") || !strings.Contains(note, j.ID) {
457 t.Fatalf("stalled drain note = %q, want stalled update for %s", note, j.ID)
458 }
459 // The warning is once per job.
460 time.Sleep(30 * time.Millisecond)
461 if again := m.DrainCompletedNote(); again != "" {
462 t.Fatalf("second stalled drain note = %q, want empty", again)
463 }
464 }
465
466 // Killed status is observable as soon as Kill returns, before the run goroutine
467 // unwinds — otherwise a slow cancelled process tree (Windows taskkill + WaitDelay
468 // drain) leaves Wait reporting Running until the goroutine finally returns, which
469 // is the TestBackgroundKill flake. The job here stays blocked past ctx.Done.
470 func TestKillStatusObservableBeforeGoroutineReturns(t *testing.T) {
471 m := NewManager(event.Discard)
472 defer m.Close()
473
474 release := make(chan struct{})
475 j := m.Start("bash", "", func(ctx context.Context, _ io.Writer) (string, error) {
476 <-ctx.Done()
477 <-release // simulate a teardown that hasn't returned yet
478 return "", ctx.Err()
479 })
480 if !m.Kill(j.ID) {
481 t.Fatal("Kill on a running job returned false")
482 }
483
484 // Short timeout: the goroutine is still blocked, so Wait can only know the
485 // status if Kill set it synchronously.
486 res := m.Wait(context.Background(), []string{j.ID}, 1)
487 if len(res) != 1 || res[0].Status != Killed {
488 t.Fatalf("want Killed before the goroutine returns, got %+v", res)
489 }
490 if n := len(m.Running()); n != 1 {
491 t.Fatalf("a killed-but-unwinding job must remain operationally running, got %d", n)
492 }
493
494 close(release)
495 m.Wait(context.Background(), []string{j.ID}, 5)
496 if n := len(m.Running()); n != 0 {
497 t.Fatalf("job remained operationally running after goroutine exit, got %d", n)
498 }
499 }
500
501 // Close cancels every still-running job.
502 func TestCloseCancels(t *testing.T) {
503 m := NewManager(event.Discard)
504
505 started := make(chan struct{})
506 j := m.Start("task", "", func(ctx context.Context, _ io.Writer) (string, error) {
507 close(started)
508 <-ctx.Done()
509 return "", ctx.Err()
510 })
511 <-started
512 m.Close()
513
514 res := m.Wait(context.Background(), []string{j.ID}, 5)
515 if len(res) != 1 || res[0].Status != Killed {
516 t.Fatalf("want Killed after Close, got %+v", res)
517 }
518 }
519
520 // Running reflects only in-flight jobs.
521 func TestRunning(t *testing.T) {
522 m := NewManager(event.Discard)
523 defer m.Close()
524
525 release := make(chan struct{})
526 j := m.Start("task", "label", func(ctx context.Context, _ io.Writer) (string, error) {
527 <-release
528 return "answer", nil
529 })
530 waitFor(t, func() bool { return len(m.Running()) == 1 })
531 if r := m.Running()[0]; r.ID != j.ID || r.Label != "label" {
532 t.Errorf("running view = %+v, want id=%s label=label", r, j.ID)
533 }
534 close(release)
535 m.Wait(context.Background(), []string{j.ID}, 5)
536 waitFor(t, func() bool { return len(m.Running()) == 0 })
537 }
538
539 func TestSessionScopedOperations(t *testing.T) {
540 m := NewManager(event.Discard)
541 defer m.Close()
542
543 releaseA := make(chan struct{})
544 releaseB := make(chan struct{})
545 a := m.StartForSession("session-a", "bash", "a", func(_ context.Context, out io.Writer) (string, error) {
546 io.WriteString(out, "from-a\n")
547 <-releaseA
548 return "", nil
549 })
550 b := m.StartForSession("session-b", "bash", "b", func(_ context.Context, out io.Writer) (string, error) {
551 io.WriteString(out, "from-b\n")
552 <-releaseB
553 return "", nil
554 })
555
556 waitFor(t, func() bool {
557 txt, _, ok := m.OutputForSession("session-a", a.ID)
558 return ok && strings.Contains(txt, "from-a")
559 })
560 if _, _, ok := m.OutputForSession("session-a", b.ID); ok {
561 t.Fatal("session-a should not read session-b output")
562 }
563 if got := m.RunningForSession("session-a"); len(got) != 1 || got[0].ID != a.ID {
564 t.Fatalf("session-a running = %+v, want only %s", got, a.ID)
565 }
566 if got := m.WaitForSession(context.Background(), "session-a", []string{b.ID}, 1); len(got) != 0 {
567 t.Fatalf("session-a wait on session-b job = %+v, want none", got)
568 }
569 if m.KillForSession("session-a", b.ID) {
570 t.Fatal("session-a should not kill session-b job")
571 }
572
573 close(releaseA)
574 res := m.WaitForSession(context.Background(), "session-a", []string{a.ID}, 5)
575 if len(res) != 1 || res[0].ID != a.ID || res[0].Status != Done {
576 t.Fatalf("session-a wait all = %+v, want only done %s", res, a.ID)
577 }
578 if note := m.DrainCompletedNoteForSession("session-b"); note != "" {
579 t.Fatalf("session-b drain before completion = %q, want empty", note)
580 }
581 if note := m.DrainCompletedNoteForSession("session-a"); !strings.Contains(note, a.ID) {
582 t.Fatalf("session-a drain = %q, want %s", note, a.ID)
583 }
584
585 close(releaseB)
586 m.WaitForSession(context.Background(), "session-b", []string{b.ID}, 5)
587 if note := m.DrainCompletedNoteForSession("session-b"); !strings.Contains(note, b.ID) {
588 t.Fatalf("session-b drain = %q, want %s", note, b.ID)
589 }
590 }
591
592 func TestSessionScopedNoticesUseActiveSession(t *testing.T) {
593 sink := &recordingSink{}
594 m := NewManager(sink)
595 defer m.Close()
596 m.SetActiveSession("session-a")
597
598 releaseA := make(chan struct{})
599 releaseB := make(chan struct{})
600 a := m.StartForSession("session-a", "bash", "a", func(_ context.Context, _ io.Writer) (string, error) {
601 <-releaseA
602 return "", nil
603 })
604 b := m.StartForSession("session-b", "bash", "b", func(_ context.Context, _ io.Writer) (string, error) {
605 <-releaseB
606 return "", nil
607 })
608 close(releaseB)
609 m.Wait(context.Background(), []string{b.ID}, 5)
610 for _, text := range sink.texts() {
611 if strings.Contains(text, b.ID) {
612 t.Fatalf("inactive session job notice leaked: %q", text)
613 }
614 }
615
616 close(releaseA)
617 m.Wait(context.Background(), []string{a.ID}, 5)
618 waitFor(t, func() bool {
619 for _, text := range sink.texts() {
620 if strings.Contains(text, a.ID) {
621 return true
622 }
623 }
624 return false
625 })
626 }
627
628 func TestDestroySessionCancelsOwnedJobsAndSuppressesCompletion(t *testing.T) {
629 m := NewManager(event.Discard)
630 defer m.Close()
631
632 started := make(chan struct{})
633 j := m.StartForSession("session-a", "task", "cleanup", func(ctx context.Context, _ io.Writer) (string, error) {
634 close(started)
635 <-ctx.Done()
636 return "", ctx.Err()
637 })
638 <-started
639
640 done := m.DestroySession("session-a")
641 if len(done) != 1 {
642 t.Fatalf("DestroySession returned %d done channels, want 1", len(done))
643 }
644 if !m.IsDestroying("session-a") {
645 t.Fatal("session-a should be marked destroying")
646 }
647 <-done[0]
648 res := m.WaitForSession(context.Background(), "session-a", []string{j.ID}, 5)
649 if len(res) != 1 || res[0].Status != Killed {
650 t.Fatalf("destroyed job result = %+v, want killed", res)
651 }
652 if note := m.DrainCompletedNoteForSession("session-a"); note != "" {
653 t.Fatalf("destroyed session should not queue completion note, got %q", note)
654 }
655 m.FinishDestroySession("session-a")
656 if m.IsDestroying("session-a") {
657 t.Fatal("session-a should no longer be marked destroying")
658 }
659 }
660
661 func TestDestroySessionWaitsForAlreadyKilledJobs(t *testing.T) {
662 m := NewManager(event.Discard)
663 defer m.Close()
664
665 started := make(chan struct{})
666 release := make(chan struct{})
667 j := m.StartForSession("session-a", "task", "cleanup", func(ctx context.Context, _ io.Writer) (string, error) {
668 close(started)
669 <-ctx.Done()
670 <-release
671 return "", ctx.Err()
672 })
673 <-started
674
675 if !m.KillForSession("session-a", j.ID) {
676 t.Fatal("KillForSession on a running job returned false")
677 }
678 waitFor(t, func() bool {
679 _, status, ok := m.OutputForSession("session-a", j.ID)
680 return ok && status == Killed
681 })
682
683 done := m.DestroySession("session-a")
684 if len(done) != 1 {
685 t.Fatalf("DestroySession returned %d done channels, want 1", len(done))
686 }
687 if !m.IsDestroying("session-a") {
688 t.Fatal("session-a should be marked destroying")
689 }
690 select {
691 case <-done[0]:
692 t.Fatal("done channel closed before killed job finished unwinding")
693 default:
694 }
695
696 close(release)
697 select {
698 case <-done[0]:
699 case <-time.After(2 * time.Second):
700 t.Fatal("timed out waiting for killed job to unwind")
701 }
702 res := m.WaitForSession(context.Background(), "session-a", []string{j.ID}, 5)
703 if len(res) != 1 || res[0].Status != Killed {
704 t.Fatalf("destroyed job result = %+v, want killed", res)
705 }
706 if note := m.DrainCompletedNoteForSession("session-a"); note != "" {
707 t.Fatalf("destroyed session should not queue completion note, got %q", note)
708 }
709 m.FinishDestroySession("session-a")
710 if m.IsDestroying("session-a") {
711 t.Fatal("session-a should no longer be marked destroying")
712 }
713 }
714
715 func TestWaitTeardownTimesOutForNonCooperativeJob(t *testing.T) {
716 m := NewManager(event.Discard)
717 release := make(chan struct{})
718 var releaseOnce sync.Once
719 releaseJob := func() { releaseOnce.Do(func() { close(release) }) }
720 defer func() {
721 releaseJob()
722 m.Close()
723 }()
724
725 started := make(chan struct{})
726 j := m.StartForSession("session-a", "task", "cleanup", func(ctx context.Context, _ io.Writer) (string, error) {
727 close(started)
728 <-ctx.Done()
729 <-release
730 return "", ctx.Err()
731 })
732 <-started
733
734 handle := m.BeginDestroySession("session-a")
735 start := time.Now()
736 result := m.WaitTeardown(context.Background(), handle, 25*time.Millisecond)
737 elapsed := time.Since(start)
738 if elapsed > 500*time.Millisecond {
739 t.Fatalf("WaitTeardown took %s, want bounded wait", elapsed)
740 }
741 if len(result.TimedOut) != 1 {
742 t.Fatalf("timed out jobs = %+v, want one", result.TimedOut)
743 }
744 got := result.TimedOut[0]
745 if got.ID != j.ID || got.Kind != "task" || got.Label != "cleanup" || got.Waited <= 0 {
746 t.Fatalf("timed out job = %+v, want id=%s kind=task label=cleanup waited>0", got, j.ID)
747 }
748 if note := m.DrainCompletedNoteForSession("session-a"); note != "" {
749 t.Fatalf("destroyed session should not queue completion note, got %q", note)
750 }
751 if !m.IsDestroying("session-a") {
752 t.Fatal("session-a should stay destroying until delayed cleanup finishes")
753 }
754
755 releaseJob()
756 for _, ch := range handle.DoneChannels() {
757 select {
758 case <-ch:
759 case <-time.After(2 * time.Second):
760 t.Fatal("timed out waiting for delayed job unwind")
761 }
762 }
763 m.FinishDestroySession("session-a")
764 if m.IsDestroying("session-a") {
765 t.Fatal("session-a should no longer be destroying after Finish")
766 }
767 }
768
769 func TestCloseWithGraceTimesOutForNonCooperativeJob(t *testing.T) {
770 m := NewManager(event.Discard)
771 release := make(chan struct{})
772 var releaseOnce sync.Once
773 releaseJob := func() { releaseOnce.Do(func() { close(release) }) }
774 defer func() {
775 releaseJob()
776 m.Close()
777 }()
778
779 started := make(chan struct{})
780 j := m.Start("task", "cleanup", func(ctx context.Context, _ io.Writer) (string, error) {
781 close(started)
782 <-ctx.Done()
783 <-release
784 return "", ctx.Err()
785 })
786 <-started
787
788 start := time.Now()
789 result := m.CloseWithGrace(25 * time.Millisecond)
790 elapsed := time.Since(start)
791 if elapsed > 500*time.Millisecond {
792 t.Fatalf("CloseWithGrace took %s, want bounded wait", elapsed)
793 }
794 if len(result.TimedOut) != 1 {
795 t.Fatalf("timed out jobs = %+v, want one", result.TimedOut)
796 }
797 if got := result.TimedOut[0]; got.ID != j.ID || got.Kind != "task" || got.Label != "cleanup" || got.Waited <= 0 {
798 t.Fatalf("timed out job = %+v, want id=%s kind=task label=cleanup waited>0", got, j.ID)
799 }
800 if running := m.Running(); len(running) != 1 || running[0].ID != j.ID {
801 t.Fatalf("timed-out close job must remain operationally running, got %+v", running)
802 }
803
804 releaseJob()
805 res := m.Wait(context.Background(), []string{j.ID}, 5)
806 if len(res) != 1 || res[0].Status != Killed {
807 t.Fatalf("want killed after delayed close cleanup, got %+v", res)
808 }
809 if running := m.Running(); len(running) != 0 {
810 t.Fatalf("close job remained running after delayed cleanup, got %+v", running)
811 }
812 }
813
813 lines GO