carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 03d617dec9d7562e927d7bbb9a38059b1fdee275 parent 6b8dad2
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}}}}