cli: pin Ctrl-C once and twice for carlos run, including a page in flight (run spec §5.7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+170
−0
run_test.go+170 −0
diff --git a/run_test.go b/run_test.go| index b85fa539f..c9031fb8a 100644 |
| --- a/run_test.go |
| +++ b/run_test.go |
| @@ -14,6 +14,7 @@ import ( |
| "path/filepath" |
| "slices" |
| "strings" |
| + "sync" |
| "sync/atomic" |
| "testing" |
| "time" |
| @@ -345,6 +346,24 @@ type scriptedRun struct { |
| cancels int |
| expires int |
| created runs.Meta |
| + |
| + // mu guards cancels and pageCalls, which a test reads from its own |
| + // goroutine while followRun's goroutine writes them. |
| + mu sync.Mutex |
| + pageCalls int |
| +} |
| + |
| +// cancelsMade and pagesRead are safe to call while followRun is running. |
| +func (s *scriptedRun) cancelsMade() int { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + return s.cancels |
| +} |
| + |
| +func (s *scriptedRun) pagesRead() int { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + return s.pageCalls |
| } |
| func (s *scriptedRun) create(_ context.Context, m runs.Meta, _ []string) (runs.Created, error) { |
| @@ -355,6 +374,9 @@ func (s *scriptedRun) create(_ context.Context, m runs.Meta, _ []string) (runs.C |
| return runs.Created{ID: "run_a", State: "queued"}, nil |
| } |
| func (s *scriptedRun) page(context.Context, string, int64) (runs.Page, error) { |
| + s.mu.Lock() |
| + s.pageCalls++ |
| + s.mu.Unlock() |
| if len(s.pageErrs) > 0 { |
| err := s.pageErrs[0] |
| s.pageErrs = s.pageErrs[1:] |
| @@ -372,7 +394,9 @@ func (s *scriptedRun) page(context.Context, string, int64) (runs.Page, error) { |
| return p, nil |
| } |
| func (s *scriptedRun) cancel(context.Context, string) (string, error) { |
| + s.mu.Lock() |
| s.cancels++ |
| + s.mu.Unlock() |
| if len(s.cancelErrs) > 0 { |
| err := s.cancelErrs[0] |
| s.cancelErrs = s.cancelErrs[1:] |
| @@ -788,6 +812,152 @@ func TestFollowRunInterrupts(t *testing.T) { |
| }) |
| } |
| +// gatedRun holds one page call open, as a request in flight: entered is |
| +// closed when the call arrives, and the call returns when release is closed. |
| +type gatedRun struct { |
| + *scriptedRun |
| + holdCall int // the page call (1-based) to hold |
| + entered chan struct{} |
| + release chan struct{} |
| + ctxErr error // the held call's context error when it returned |
| +} |
| + |
| +func (g *gatedRun) page(ctx context.Context, id string, after int64) (runs.Page, error) { |
| + if g.pagesRead()+1 == g.holdCall { |
| + close(g.entered) |
| + <-g.release |
| + g.ctxErr = ctx.Err() |
| + } |
| + return g.scriptedRun.page(ctx, id, after) |
| +} |
| + |
| +func newGatedRun(s *scriptedRun, holdCall int) *gatedRun { |
| + return &gatedRun{scriptedRun: s, holdCall: holdCall, entered: make(chan struct{}), release: make(chan struct{})} |
| +} |
| + |
| +func awaitFollowRun(t *testing.T, done <-chan int, what string) int { |
| + t.Helper() |
| + select { |
| + case code := <-done: |
| + return code |
| + case <-time.After(2 * time.Second): |
| + t.Fatal(what) |
| + return 0 |
| + } |
| +} |
| + |
| +func awaitSignal(t *testing.T, ch <-chan struct{}, what string) { |
| + t.Helper() |
| + select { |
| + case <-ch: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal(what) |
| + } |
| +} |
| + |
| +func TestFollowRunCtrlCOnceCancelsTwiceExits(t *testing.T) { |
| + f := newTermFx() |
| + s := &scriptedRun{pages: []runs.Page{{State: "picked"}}} |
| + done := make(chan int, 1) |
| + go func() { done <- followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", time.Time{}) }() |
| + f.sig <- os.Interrupt |
| + deadline := time.Now().Add(2 * time.Second) |
| + for s.cancelsMade() == 0 && time.Now().Before(deadline) { |
| + time.Sleep(5 * time.Millisecond) |
| + } |
| + if got := s.cancelsMade(); got != 1 { |
| + t.Fatalf("the first Ctrl-C made %d cancels, want 1", got) |
| + } |
| + // The first Ctrl-C does not end the wait: the run is still being polled. |
| + seen := s.pagesRead() |
| + for s.pagesRead() < seen+3 && time.Now().Before(deadline) { |
| + time.Sleep(5 * time.Millisecond) |
| + } |
| + if s.pagesRead() < seen+3 { |
| + t.Fatal("followRun stopped polling after the first Ctrl-C") |
| + } |
| + select { |
| + case code := <-done: |
| + t.Fatalf("followRun returned %d after one Ctrl-C", code) |
| + default: |
| + } |
| + f.sig <- os.Interrupt |
| + if code := awaitFollowRun(t, done, "the second Ctrl-C did not stop waiting"); code != 130 { |
| + t.Fatalf("exit %d, want 130", code) |
| + } |
| + if got := s.cancelsMade(); got != 1 { |
| + t.Fatalf("%d cancels, want 1: the second Ctrl-C does not cancel again", got) |
| + } |
| + if len(s.acked) != 0 { |
| + t.Fatalf("acked %v: nothing was read to the end", s.acked) |
| + } |
| + if got := f.err.String(); got != "cancelling run run_a. press Ctrl-C again to stop waiting\n" { |
| + t.Fatalf("stderr %q", got) |
| + } |
| +} |
| + |
| +func TestFollowRunCtrlCWhileAPageIsInFlight(t *testing.T) { |
| + picked := runs.Page{State: "picked"} |
| + done := runs.Page{State: "done", Result: &runs.Result{Cancelled: true, Started: time.Unix(1_700_000_000, 0)}} |
| + t.Run("the first is taken when the request returns, before the next poll", func(t *testing.T) { |
| + f := newTermFx() |
| + g := newGatedRun(&scriptedRun{pages: []runs.Page{picked, done}}, 1) |
| + codes := make(chan int, 1) |
| + go func() { |
| + codes <- followRun(context.Background(), g, f.term(), "shop.example.com", "run_a", time.Time{}) |
| + }() |
| + awaitSignal(t, g.entered, "the first page request was never made") |
| + f.sig <- os.Interrupt |
| + // The request is not abandoned, and nothing is cancelled until it is back. |
| + if got := g.cancelsMade(); got != 0 { |
| + t.Fatalf("%d cancels while the page request was in flight", got) |
| + } |
| + close(g.release) |
| + if code := awaitFollowRun(t, codes, "the run was not followed to its result"); code != 130 { |
| + t.Fatalf("exit %d, want 130", code) |
| + } |
| + if g.ctxErr != nil { |
| + t.Fatalf("the request in flight was cut off: %v", g.ctxErr) |
| + } |
| + if got := g.cancelsMade(); got != 1 { |
| + t.Fatalf("%d cancels, want 1", got) |
| + } |
| + if got := g.pagesRead(); got != 2 { |
| + t.Fatalf("%d page requests, want 2: the one in flight and the one that carries the result", got) |
| + } |
| + if got := f.err.String(); got != "cancelling run run_a. press Ctrl-C again to stop waiting\nshop.example.com: the command was cancelled\n" { |
| + t.Fatalf("stderr %q", got) |
| + } |
| + }) |
| + t.Run("the second is taken when the request returns, with no further poll", func(t *testing.T) { |
| + f := newTermFx() |
| + f.sig <- os.Interrupt // the first, handled after the first page |
| + g := newGatedRun(&scriptedRun{pages: []runs.Page{picked}}, 2) |
| + codes := make(chan int, 1) |
| + go func() { |
| + codes <- followRun(context.Background(), g, f.term(), "shop.example.com", "run_a", time.Time{}) |
| + }() |
| + awaitSignal(t, g.entered, "the second page request was never made") |
| + if got := g.cancelsMade(); got != 1 { |
| + t.Fatalf("%d cancels before the second page request, want 1", got) |
| + } |
| + f.sig <- os.Interrupt |
| + close(g.release) |
| + if code := awaitFollowRun(t, codes, "the second Ctrl-C did not stop waiting"); code != 130 { |
| + t.Fatalf("exit %d, want 130", code) |
| + } |
| + if got := g.pagesRead(); got != 2 { |
| + t.Fatalf("%d page requests, want 2: no poll after the second Ctrl-C", got) |
| + } |
| + if got := g.cancelsMade(); got != 1 { |
| + t.Fatalf("%d cancels, want 1", got) |
| + } |
| + if len(g.acked) != 0 { |
| + t.Fatalf("acked %v", g.acked) |
| + } |
| + }) |
| +} |
| + |
| func TestStartRunPrintsTheFollowCommand(t *testing.T) { |
| f := newTermFx() |
| s := &scriptedRun{pages: []runs.Page{{State: "done", Result: &runs.Result{Exit: 0}}}} |