diff --git a/.changes/v1.15/BUG FIXES-20251119-230813.yaml b/.changes/v1.15/BUG FIXES-20251119-230813.yaml new file mode 100644 index 000000000000..b95e0c0ffcf0 --- /dev/null +++ b/.changes/v1.15/BUG FIXES-20251119-230813.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: 'core: Fixed an issue where early SIGINT (Ctrl+C) was ignored during the graph walk phase' +time: 2025-11-19T23:08:13.02625091+02:00 +custom: + Issue: "31371" diff --git a/internal/backend/local/backend_apply.go b/internal/backend/local/backend_apply.go index 0dd3704950c4..74087bf8eb1b 100644 --- a/internal/backend/local/backend_apply.go +++ b/internal/backend/local/backend_apply.go @@ -63,6 +63,20 @@ func (b *Local) opApply( op.ReportResult(runningOp, diags) return } + + // Set the stop context so that the core context can check for early + // cancellation. + lr.Core.SetStopContext(stopCtx) + + // Watch for stop signals and stop the core context. + // We do this in a goroutine because Core.Stop blocks until the operation + // completes, and we want to be able to return from opApply if necessary + // (though usually we'll wait for the operation to complete). + go func() { + <-cancelCtx.Done() + lr.Core.Stop() + }() + // the state was locked during successful context creation; unlock the state // when the operation completes defer func() { @@ -113,6 +127,17 @@ func (b *Local) opApply( return } + // If we were stopped during the plan, we should return immediately. + if stopCtx.Err() != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Operation cancelled", + "The operation was cancelled.", + )) + op.ReportResult(runningOp, diags) + return + } + trivialPlan := !plan.Applyable hasUI := op.UIOut != nil && op.UIIn != nil mustConfirm := hasUI && !op.AutoApprove && !trivialPlan diff --git a/internal/backend/local/backend_apply_stop_test.go b/internal/backend/local/backend_apply_stop_test.go new file mode 100644 index 000000000000..1181ab7e9f41 --- /dev/null +++ b/internal/backend/local/backend_apply_stop_test.go @@ -0,0 +1,82 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package local + +import ( + "context" + "testing" + "time" + + "github.com/hashicorp/terraform/internal/backend/backendrun" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/providers" +) + +func TestLocal_applyStopEarly(t *testing.T) { + b := TestLocal(t) + + schema := applyFixtureSchema() + schema.DataSources = map[string]providers.Schema{ + "test_data": { + Body: &configschema.Block{}, + }, + } + + // Create a provider that blocks on ReadDataSource + p := TestLocalProvider(t, b, "test", schema) + + // We need to make sure ReadDataSource is called and blocks + readCalled := make(chan struct{}) + block := make(chan struct{}) + + // Override the ReadDataSourceFn + p.ReadDataSourceFn = func(req providers.ReadDataSourceRequest) providers.ReadDataSourceResponse { + close(readCalled) + <-block + return providers.ReadDataSourceResponse{ + State: req.Config, + } + } + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-stop") + defer configCleanup() + + // Create a context that we can cancel + ctx, cancel := context.WithCancel(context.Background()) + + // Cancel immediately to simulate "early" interrupt + cancel() + + run, err := b.Operation(ctx, op) + if err != nil { + t.Fatalf("bad: %s", err) + } + + // Wait for result + doneCh := make(chan struct{}) + go func() { + <-run.Done() + close(doneCh) + }() + + select { + case <-doneCh: + // Success (it returned) + case <-readCalled: + // It reached the provider! This means it didn't stop early. + close(block) // Unblock to cleanup + t.Fatal("Operation reached provider despite early cancellation") + case <-time.After(5 * time.Second): + t.Fatal("Operation timed out") + } + + if run.Result == backendrun.OperationSuccess { + t.Fatal("Operation succeeded but should have been cancelled") + } + + if errOutput := done(t).Stderr(); errOutput != "" { + // We expect some error output due to cancellation, but let's log it just in case + t.Logf("error output:\n%s", errOutput) + } +} diff --git a/internal/backend/local/backend_refresh.go b/internal/backend/local/backend_refresh.go index 14e9301d9cc2..2e8c745ae3b8 100644 --- a/internal/backend/local/backend_refresh.go +++ b/internal/backend/local/backend_refresh.go @@ -55,6 +55,10 @@ func (b *Local) opRefresh( return } + // Set the stop context so that the core context can check for early + // cancellation. + lr.Core.SetStopContext(stopCtx) + // the state was locked during successful context creation; unlock the state // when the operation completes defer func() { diff --git a/internal/backend/local/testdata/apply-stop/main.tf b/internal/backend/local/testdata/apply-stop/main.tf new file mode 100644 index 000000000000..dd1b59ac6b6e --- /dev/null +++ b/internal/backend/local/testdata/apply-stop/main.tf @@ -0,0 +1,2 @@ +data "test_data" "foo" { +} diff --git a/internal/terraform/context.go b/internal/terraform/context.go index 425c4faf66ad..3aebd68f2544 100644 --- a/internal/terraform/context.go +++ b/internal/terraform/context.go @@ -106,10 +106,22 @@ type Context struct { runCond *sync.Cond runContext context.Context runContextCancel context.CancelFunc + stopContext context.Context } // (additional methods on Context can be found in context_*.go files.) +// SetStopContext sets a context that, when cancelled, will cause any +// currently-running or future operation on this Context to be cancelled. +// +// This is an alternative to calling Stop(), for cases where the caller +// already has a context representing the lifecycle of the operation. +func (c *Context) SetStopContext(ctx context.Context) { + c.l.Lock() + defer c.l.Unlock() + c.stopContext = ctx +} + // NewContext creates a new Context structure. // // Once a Context is created, the caller must not access or mutate any of diff --git a/internal/terraform/context_apply.go b/internal/terraform/context_apply.go index dc894b075510..8af2658c91eb 100644 --- a/internal/terraform/context_apply.go +++ b/internal/terraform/context_apply.go @@ -93,6 +93,13 @@ func (c *Context) Apply(plan *plans.Plan, config *configs.Config, opts *ApplyOpt // to be nil even when the state isn't, if the apply didn't complete enough for // the evaluation scope to produce consistent results. func (c *Context) ApplyAndEval(plan *plans.Plan, config *configs.Config, opts *ApplyOpts) (*states.State, *lang.Scope, tfdiags.Diagnostics) { + c.l.Lock() + if c.stopContext != nil && c.stopContext.Err() != nil { + c.l.Unlock() + return nil, nil, nil + } + c.l.Unlock() + defer c.acquireRun("apply")() var diags tfdiags.Diagnostics diff --git a/internal/terraform/context_import.go b/internal/terraform/context_import.go index 6629e8665f3b..28ff534e86e6 100644 --- a/internal/terraform/context_import.go +++ b/internal/terraform/context_import.go @@ -49,6 +49,13 @@ type ImportTarget struct { // an import there is a failure, all previously imported resources remain // imported. func (c *Context) Import(config *configs.Config, prevRunState *states.State, opts *ImportOpts) (*states.State, tfdiags.Diagnostics) { + c.l.Lock() + if c.stopContext != nil && c.stopContext.Err() != nil { + c.l.Unlock() + return nil, nil + } + c.l.Unlock() + var diags tfdiags.Diagnostics // Hold a lock since we can modify our own state here diff --git a/internal/terraform/context_validate.go b/internal/terraform/context_validate.go index af484a579535..7e13f148db08 100644 --- a/internal/terraform/context_validate.go +++ b/internal/terraform/context_validate.go @@ -60,6 +60,13 @@ type ValidateOpts struct { // // The opts can be nil, and the ExternalProviders field of the opts can be nil. func (c *Context) Validate(config *configs.Config, opts *ValidateOpts) tfdiags.Diagnostics { + c.l.Lock() + if c.stopContext != nil && c.stopContext.Err() != nil { + c.l.Unlock() + return nil + } + c.l.Unlock() + defer c.acquireRun("validate")() var diags tfdiags.Diagnostics diff --git a/internal/terraform/context_walk.go b/internal/terraform/context_walk.go index 55d0bd9571a2..65295fa68b60 100644 --- a/internal/terraform/context_walk.go +++ b/internal/terraform/context_walk.go @@ -180,6 +180,13 @@ func (c *Context) graphWalker(graph *Graph, operation walkOperation, opts *graph deferred.SetExternalDependencyDeferred() } + c.l.Lock() + stopCtx := c.stopContext + c.l.Unlock() + if stopCtx == nil { + stopCtx = c.runContext + } + return &ContextGraphWalker{ Context: c, State: state, @@ -196,7 +203,7 @@ func (c *Context) graphWalker(graph *Graph, operation walkOperation, opts *graph ExternalProviderConfigs: opts.ExternalProviderConfigs, MoveResults: opts.MoveResults, Operation: operation, - StopContext: c.runContext, + StopContext: stopCtx, PlanTimestamp: opts.PlanTimeTimestamp, functionResults: opts.FunctionResults, Forget: opts.Forget, diff --git a/internal/terraform/graph.go b/internal/terraform/graph.go index d641fb57b4d8..b60f47653474 100644 --- a/internal/terraform/graph.go +++ b/internal/terraform/graph.go @@ -199,7 +199,20 @@ func (g *Graph) walk(walker GraphWalker) tfdiags.Diagnostics { return } - return g.AcyclicGraph.Walk(walkFn) + diags := g.AcyclicGraph.Walk(walkFn) + + // If the operation was cancelled, we'll get a lot of "Operation cancelled" + // errors from the graph walk. We want to filter these out so that we don't + // spam the user with them. The backend will add a single "Operation cancelled" + // error if the context was cancelled. + var filtered tfdiags.Diagnostics + for _, d := range diags { + if d.Description().Summary == "Operation cancelled" { + continue + } + filtered = filtered.Append(d) + } + return filtered } // ResourceGraph derives a graph containing addresses of only the nodes in the diff --git a/internal/terraform/graph_walk_context.go b/internal/terraform/graph_walk_context.go index 7dbe5e19d1e0..8d4634d430ff 100644 --- a/internal/terraform/graph_walk_context.go +++ b/internal/terraform/graph_walk_context.go @@ -159,9 +159,20 @@ func (w *ContextGraphWalker) init() { } func (w *ContextGraphWalker) Execute(ctx EvalContext, n GraphNodeExecutable) tfdiags.Diagnostics { + // Check if we've been stopped. + if w.StopContext.Err() != nil { + return tfdiags.Diagnostics{tfdiags.Sourceless(tfdiags.Error, "Operation cancelled", "The operation was cancelled.")} + } + // Acquire a lock on the semaphore w.Context.parallelSem.Acquire() defer w.Context.parallelSem.Release() + // Check again after acquiring the semaphore, in case we were stopped + // while waiting. + if w.StopContext.Err() != nil { + return tfdiags.Diagnostics{tfdiags.Sourceless(tfdiags.Error, "Operation cancelled", "The operation was cancelled.")} + } + return n.Execute(ctx, w.Operation) }