Skip to content

fix(cmd): propagate caller cancellation through lifecycle orchestration - #2167

Merged
cristim merged 3 commits into
mainfrom
fix/2151-lifecycle-cancellation
Oct 10, 2026
Merged

cristim merged 3 commits into
mainfrom
fix/2151-lifecycle-cancellation

Conversation

@cristim

@cristim cristim commented Oct 10, 2026

Copy link
Copy Markdown
Member

Summary

Caller cancellation now reaches the lifecycle queries and every pre-purchase boundary, and a purchase already in flight is never aborted.

  • Invocation context: runTool uses signal.NotifyContext(os.Interrupt) (invocationContext). stop is deferred, never called on the first signal, so SIGINT stays registered and further Ctrl-Cs are absorbed instead of hard-exiting mid-purchase. First cancel prints once: "stopping after the in-flight purchase; Ctrl-\ force-quits and may lose its audit record". shutdownRequested is kept (set from the context); SIGINT only, SIGTERM out of scope.
  • Pre-purchase phases are terminal on cancel/deadline (nothing is bought, partial data is discarded, dry run is not exempt): engine-version queries (the two context.Background() calls in filterAndAdjustRecommendations now use the caller's ctx; queryInstanceVersions/queryMajorVersions return the ctx error instead of an empty map), region discovery, the recommendation fetch, the coverage fetch, checkDuplicates and checkDuplicatesForCSVRegion. Provider errors with an active context stay recoverable. Pre-purchase interrupts exit non-zero via the existing log.Fatalf startup pattern.
  • Purchases: PurchaseCommitment runs on context.WithoutCancel(ctx) in executePurchasePipeline and processPurchaseLoop (cancelling a request mid-flight can leave AWS having created the commitment while the CLI records a failure, so a re-run double-buys). Per-call SDK timeouts still bound it (~30s).
  • Audit ordering: processPurchaseLoop slept before writing the audit record; it now writes the record first, then waits (waitBetweenPurchases, which returns at once on cancel), then checks ctx.
  • Report: for whatever ran, the CSV and summary are still produced, plus "Run interrupted: N of M recommendation(s) attempted". Any errored purchase in the run is flagged "verify in AWS before re-running". Audit status is unchanged (the go#301 sentinel has not landed at this pin); re-runs rely on checkDuplicates.

Not in this PR

No go.mod bump.

Verification (offline: stubbed transport and mocks, no live cloud calls, no purchases)

  • TestExecuteAndReport_CancelDuringPurchaseFinishesInFlightAndReportsWhatRan: the stub cancels the parent ctx during CreateSavingsPlan and asserts the request context still has Err()==nil, then asserts exactly one purchase request, one audit row (success), the CSV report, and "Run interrupted: 1 of 3".
  • Audit-before-delay tests for both loops (the wait hook inspects the audit file when the delay starts), the processPurchaseLoop in-flight test, a signal test that two SIGINTs do not kill the process, a notice-once test, and a prompt-return test for the wait.
  • Pre-purchase tests with an already-cancelled / expired context and stubs returning ctx.Err(): fetchEngineVersionData, filterAndAdjustRecommendations, prepareCSVPurchaseRun (dry and real), fetchExistingCoverage (dry run too), handleRegionDiscoveryError, fetchRecommendationsForRegion, fetchAllRecs (mid-fan-out and last region), checkDuplicates, checkDuplicatesForCSVRegion; plus an active-context provider error that stays recoverable.
  • Mutations checked: dropping WithoutCancel (pipeline and loop), moving the wait before the audit write (both loops), calling stop() early (the signal test kills the test binary), and removing each ctx.Err() guard are each caught (the two redundant lifecycle guards were mutated as a pair; one redundant after-fan-out guard was deleted as unreachable).
  • make lint clean, gocyclo -over 10 clean, go test ./cmd/ -timeout 25m passes.
  • Existing PurchaseCommitment mocks matched the exact parent ctx; they now match any ctx (purchases run on a detached ctx).

Closes #2151

🤖 Generated with Claude Code

cristim and others added 3 commits October 10, 2026 04:42
…chase

The CLI ran on context.Background(), so Ctrl-C only set a flag polled
between purchases. SIGINT now cancels the invocation context; further
Ctrl-Cs are absorbed until the run returns. The purchase call runs on a
context detached from that cancellation, so an in-flight purchase is not
aborted with its result lost; its audit record is written (before the
rate-limit delay, which previously ran first in the --input-csv loop), the
delay returns on cancel, no further purchase starts, and the report and an
"interrupted: N of M attempted" summary are still produced. Errored
purchases in an interrupted run are flagged "verify in AWS before re-running".

Refs #2151

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The lifecycle queries ran on context.Background() and, with a real context,
swallowed cancellation into empty maps; region discovery, the recommendation
fetch, the coverage fetch and the duplicate checks did the same. A canceled
or expired context is now returned as an error at each boundary (nothing is
purchased, partial data is discarded, a dry run is not exempt), while
provider errors with an active context stay recoverable.

Closes #2151

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Purchase calls now run on a context detached from cancellation, so mocks
that matched the exact parent context no longer match.

Refs #2151

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 12 billable files and costs up to $3.00.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 25 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 88 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-cli/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: d7eefb0f-cd33-4836-afe8-e238a406c13b

📥 Commits

Reviewing files that changed from the base of the PR and between 0ab66ab and 9ebfcaf.


📒 Files selected for processing (12)
  • CHANGELOG.md
  • cmd/cancellation_prepurchase_test.go
  • cmd/cancellation_test.go
  • cmd/interrupt.go
  • cmd/main.go
  • cmd/multi_service.go
  • cmd/multi_service_coverage_test.go
  • cmd/multi_service_helpers.go
  • cmd/multi_service_helpers_test.go
  • cmd/multi_service_max_instances_test.go
  • cmd/multi_service_test.go
  • cmd/purchase_safeguards_2121_test.go

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-quarter Within the quarter impact/few Limited audience effort/s Hours type/bug Defect labels Oct 10, 2026
@cristim

cristim commented Oct 10, 2026

Copy link
Copy Markdown
Member Author

Gate verdict: APPROVE at 9ebfcaf5a390a6b35eb859252e96030a9e55b8a3 (independent adversarial review, Opus 5.5 claude-opus-5-5, money path)

Local evidence (fresh git archive of the head, GOTOOLCHAIN=go1.26.9, macOS, AWS routed to AWS_ENDPOINT_URL=http://127.0.0.1:9 with fake creds; no live AWS, no purchases):

  • go build ./..., go vet ./..., golangci-lint run: OK, 0 issues.
  • go test ./... -count=1 -timeout 25m: exit 0 (cmd 547s).
  • Cancel/signal tests -race -count=30 (InvocationContext, RegisterShutdown, Cancel*, AuditRecordWrittenBeforeDelay, Interrupt*, WaitBetween): exit 0, no flakes. The SIGINT test kills only its own test binary PID; cmd has no t.Parallel.

Mutations (each applied alone, targeted tests run):

  • KILLED: drop WithoutCancel in pipeline (in-flight ctx Err non-nil) and in processPurchaseLoop; wait before audit in both loops ("[]" should have 1 item(s)); wait ignores ctx; stop() on first signal (signal: interrupt, the test binary dies, loud); no interrupted summary; pipeline no stop check (3 audit rows instead of 1); stopRequested ignores ctx; guards in coverage map, region discovery, fetchRecommendationsForRegion, checkDuplicates, checkDuplicatesForCSVRegion, fetchAllRecs loop.
  • Paired guards (queryInstanceVersions+queryMajorVersions; both filterAndAdjust guards): each survives alone because the other catches it; removing both is KILLED.
  • SURVIVED (non-blocking): coverage getAllAWSRegions guard (test sets Regions); fetchAllRecs determine-regions guard. Both are backstopped by exitIfInterrupted after fetchAllRecs, so nothing is bought. The deleted after-fan-out guard is likewise covered by exitIfInterrupted at the only caller.

Plan amendments 1-7 hold. PurchaseCommitment's single call site (executePurchase) gets a WithoutCancel ctx from both loops; purchase SDK calls are bounded by purchasecfg (MaxAttempts 2, 15s HTTP timeout). The mock.Anything change in 9ebfcaf does not weaken coverage: detachment is asserted by the dedicated tests above, which fail under mutation.

Non-blocking nits (follow-up if wanted): mustFetchEngineVersionData/exitIfInterrupted log.Fatalf paths are untested; CSV-path and coverage-path interrupts exit with context canceled / Cannot size --target-coverage: context canceled rather than "nothing was purchased"; CHANGELOG does not state that a pre-purchase interrupt exits non-zero while a mid-purchase interrupt keeps today's exit code (#2157).

CI green at this SHA, mergeStateStatus CLEAN, labels mirror #2151.

@cristim
cristim merged commit 6a7c62c into main Oct 10, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(cli): propagate caller cancellation through lifecycle orchestration

1 participant