|
1 | 1 | package cron |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "context" |
4 | 5 | "io" |
5 | 6 | "log" |
6 | 7 | "reflect" |
@@ -523,3 +524,162 @@ func TestChainTimeout(t *testing.T) { |
523 | 524 | } |
524 | 525 | }) |
525 | 526 | } |
| 527 | + |
| 528 | +// TestTimeoutWithContextCancellation tests that onTimeout callback is NOT called |
| 529 | +// when context is canceled (vs when it times out). This kills the mutation at |
| 530 | +// chain.go:418 where ctx.Err() == context.DeadlineExceeded could be negated. |
| 531 | +func TestTimeoutWithContextCancellation(t *testing.T) { |
| 532 | + t.Run("callback not invoked on context cancellation", func(t *testing.T) { |
| 533 | + var callbackCalled int32 |
| 534 | + var errorLogged int32 |
| 535 | + |
| 536 | + callback := func(timeout time.Duration) { |
| 537 | + atomic.AddInt32(&callbackCalled, 1) |
| 538 | + } |
| 539 | + |
| 540 | + logger := &testLogCapture{} |
| 541 | + |
| 542 | + // Create a long-running job |
| 543 | + jobStarted := make(chan struct{}) |
| 544 | + jobCanFinish := make(chan struct{}) |
| 545 | + job := FuncJob(func() { |
| 546 | + close(jobStarted) |
| 547 | + <-jobCanFinish |
| 548 | + }) |
| 549 | + |
| 550 | + // Use TimeoutWithContext with a long timeout |
| 551 | + wrapper := TimeoutWithContext(logger, 10*time.Second, WithTimeoutCallback(callback)) |
| 552 | + wrappedJob := wrapper(job) |
| 553 | + |
| 554 | + // Get the timeoutContextJob to call RunWithContext |
| 555 | + tcj := wrappedJob.(*timeoutContextJob) |
| 556 | + |
| 557 | + // Create a cancellable context (NOT a timeout context) |
| 558 | + ctx, cancel := context.WithCancel(context.Background()) |
| 559 | + |
| 560 | + done := make(chan struct{}) |
| 561 | + go func() { |
| 562 | + tcj.RunWithContext(ctx) |
| 563 | + close(done) |
| 564 | + }() |
| 565 | + |
| 566 | + // Wait for job to start |
| 567 | + <-jobStarted |
| 568 | + |
| 569 | + // Cancel the context (simulates parent cancellation, NOT timeout) |
| 570 | + cancel() |
| 571 | + |
| 572 | + // Allow job to finish |
| 573 | + close(jobCanFinish) |
| 574 | + |
| 575 | + // Wait for wrapper to complete |
| 576 | + select { |
| 577 | + case <-done: |
| 578 | + case <-time.After(time.Second): |
| 579 | + t.Fatal("timeout waiting for job wrapper to complete") |
| 580 | + } |
| 581 | + |
| 582 | + // Callback should NOT be called because context was canceled, not timed out |
| 583 | + if c := atomic.LoadInt32(&callbackCalled); c != 0 { |
| 584 | + t.Errorf("expected callback NOT called on cancellation, got %d calls", c) |
| 585 | + } |
| 586 | + |
| 587 | + // Error log should NOT be called (only called on DeadlineExceeded) |
| 588 | + if c := logger.ErrorCount(); c != 0 { |
| 589 | + t.Errorf("expected no error log on cancellation, got %d", c) |
| 590 | + } |
| 591 | + |
| 592 | + _ = errorLogged // silence unused variable |
| 593 | + }) |
| 594 | + |
| 595 | + t.Run("callback invoked on actual timeout", func(t *testing.T) { |
| 596 | + var callbackCalled int32 |
| 597 | + |
| 598 | + callback := func(timeout time.Duration) { |
| 599 | + atomic.AddInt32(&callbackCalled, 1) |
| 600 | + } |
| 601 | + |
| 602 | + logger := &testLogCapture{} |
| 603 | + |
| 604 | + // Create a job that takes longer than timeout |
| 605 | + job := FuncJob(func() { |
| 606 | + time.Sleep(100 * time.Millisecond) |
| 607 | + }) |
| 608 | + |
| 609 | + wrapper := TimeoutWithContext(logger, 10*time.Millisecond, WithTimeoutCallback(callback)) |
| 610 | + wrappedJob := wrapper(job) |
| 611 | + |
| 612 | + wrappedJob.Run() |
| 613 | + |
| 614 | + // Wait for any cleanup |
| 615 | + time.Sleep(50 * time.Millisecond) |
| 616 | + |
| 617 | + // Callback SHOULD be called because job actually timed out |
| 618 | + if c := atomic.LoadInt32(&callbackCalled); c != 1 { |
| 619 | + t.Errorf("expected callback called once on timeout, got %d calls", c) |
| 620 | + } |
| 621 | + |
| 622 | + // Error log SHOULD be called |
| 623 | + if c := logger.ErrorCount(); c != 1 { |
| 624 | + t.Errorf("expected error log on timeout, got %d", c) |
| 625 | + } |
| 626 | + }) |
| 627 | +} |
| 628 | + |
| 629 | +// TestTimeoutZeroBoundary tests the boundary condition at chain.go:361 |
| 630 | +// where timeout <= 0 returns the original job unchanged. |
| 631 | +// This kills mutations that change <= to < (boundary mutation). |
| 632 | +func TestTimeoutZeroBoundary(t *testing.T) { |
| 633 | + tests := []struct { |
| 634 | + name string |
| 635 | + timeout time.Duration |
| 636 | + shouldBeWrapped bool |
| 637 | + }{ |
| 638 | + {"negative timeout returns original", -1 * time.Second, false}, |
| 639 | + {"zero timeout returns original", 0, false}, |
| 640 | + {"positive timeout wraps job", 1 * time.Millisecond, true}, |
| 641 | + } |
| 642 | + |
| 643 | + // Test Timeout wrapper using a marker job to detect wrapping |
| 644 | + for _, tt := range tests { |
| 645 | + t.Run(tt.name, func(t *testing.T) { |
| 646 | + originalJob := FuncJob(func() {}) |
| 647 | + |
| 648 | + wrapper := Timeout(DiscardLogger, tt.timeout) |
| 649 | + result := wrapper(originalJob) |
| 650 | + |
| 651 | + // For FuncJob, we can't easily detect wrapping by type. |
| 652 | + // Instead, check that when timeout <= 0, the returned job |
| 653 | + // is literally the same FuncJob (reflect.ValueOf comparison) |
| 654 | + originalVal := reflect.ValueOf(originalJob) |
| 655 | + resultVal := reflect.ValueOf(result) |
| 656 | + |
| 657 | + // If not wrapped, the pointers should be equal |
| 658 | + // If wrapped, the result is a new FuncJob |
| 659 | + isSameJob := originalVal.Pointer() == resultVal.Pointer() |
| 660 | + isWrapped := !isSameJob |
| 661 | + |
| 662 | + if isWrapped != tt.shouldBeWrapped { |
| 663 | + t.Errorf("Timeout(%v): wrapped=%v, want wrapped=%v", |
| 664 | + tt.timeout, isWrapped, tt.shouldBeWrapped) |
| 665 | + } |
| 666 | + }) |
| 667 | + } |
| 668 | + |
| 669 | + // Test TimeoutWithContext wrapper |
| 670 | + for _, tt := range tests { |
| 671 | + t.Run("WithContext_"+tt.name, func(t *testing.T) { |
| 672 | + originalJob := FuncJob(func() {}) |
| 673 | + wrapper := TimeoutWithContext(DiscardLogger, tt.timeout) |
| 674 | + result := wrapper(originalJob) |
| 675 | + |
| 676 | + // TimeoutWithContext returns *timeoutContextJob when wrapping |
| 677 | + _, isWrapped := result.(*timeoutContextJob) |
| 678 | + |
| 679 | + if isWrapped != tt.shouldBeWrapped { |
| 680 | + t.Errorf("TimeoutWithContext(%v): wrapped=%v, want wrapped=%v", |
| 681 | + tt.timeout, isWrapped, tt.shouldBeWrapped) |
| 682 | + } |
| 683 | + }) |
| 684 | + } |
| 685 | +} |
0 commit comments