Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/v1.15/BUG FIXES-20251119-230813.yaml
Original file line number Diff line number Diff line change
@@ -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"
25 changes: 25 additions & 0 deletions internal/backend/local/backend_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions internal/backend/local/backend_apply_stop_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions internal/backend/local/backend_refresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 2 additions & 0 deletions internal/backend/local/testdata/apply-stop/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
data "test_data" "foo" {
}
12 changes: 12 additions & 0 deletions internal/terraform/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions internal/terraform/context_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions internal/terraform/context_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions internal/terraform/context_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion internal/terraform/context_walk.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
15 changes: 14 additions & 1 deletion internal/terraform/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions internal/terraform/graph_walk_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}