Skip to content

Commit 57db03b

Browse files
committed
fix: respect early SIGINT during graph walk
Previously, Terraform would not respect a SIGINT (Ctrl+C) if it occurred during the graph walk phase, specifically if a provider operation was about to start or blocked. This was because the context cancellation was not being propagated effectively to the graph walker or checked before starting new operations. This commit introduces a StopContext to the Terraform Core Context, which is linked to the operation's stop context. The GraphWalker now checks this context before executing nodes. Additionally, the local backend now properly sets this context and handles early cancellation by returning immediately if the context is cancelled during the plan phase. A regression test TestLocal_applyStopEarly has been added to verify that the operation aborts immediately when cancelled. Fixes #31371
1 parent 0d2a032 commit 57db03b

File tree

12 files changed

+184
-2
lines changed

12 files changed

+184
-2
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: BUG FIXES
2+
body: 'core: Fixed an issue where early SIGINT (Ctrl+C) was ignored during the graph walk phase'
3+
time: 2025-11-19T23:08:13.02625091+02:00
4+
custom:
5+
Issue: "31371"

internal/backend/local/backend_apply.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,20 @@ func (b *Local) opApply(
6363
op.ReportResult(runningOp, diags)
6464
return
6565
}
66+
67+
// Set the stop context so that the core context can check for early
68+
// cancellation.
69+
lr.Core.SetStopContext(stopCtx)
70+
71+
// Watch for stop signals and stop the core context.
72+
// We do this in a goroutine because Core.Stop blocks until the operation
73+
// completes, and we want to be able to return from opApply if necessary
74+
// (though usually we'll wait for the operation to complete).
75+
go func() {
76+
<-cancelCtx.Done()
77+
lr.Core.Stop()
78+
}()
79+
6680
// the state was locked during successful context creation; unlock the state
6781
// when the operation completes
6882
defer func() {
@@ -113,6 +127,17 @@ func (b *Local) opApply(
113127
return
114128
}
115129

130+
// If we were stopped during the plan, we should return immediately.
131+
if stopCtx.Err() != nil {
132+
diags = diags.Append(tfdiags.Sourceless(
133+
tfdiags.Error,
134+
"Operation cancelled",
135+
"The operation was cancelled.",
136+
))
137+
op.ReportResult(runningOp, diags)
138+
return
139+
}
140+
116141
trivialPlan := !plan.Applyable
117142
hasUI := op.UIOut != nil && op.UIIn != nil
118143
mustConfirm := hasUI && !op.AutoApprove && !trivialPlan
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package local
5+
6+
import (
7+
"context"
8+
"testing"
9+
"time"
10+
11+
"github.com/hashicorp/terraform/internal/backend/backendrun"
12+
"github.com/hashicorp/terraform/internal/configs/configschema"
13+
"github.com/hashicorp/terraform/internal/providers"
14+
)
15+
16+
func TestLocal_applyStopEarly(t *testing.T) {
17+
b := TestLocal(t)
18+
19+
schema := applyFixtureSchema()
20+
schema.DataSources = map[string]providers.Schema{
21+
"test_data": {
22+
Body: &configschema.Block{},
23+
},
24+
}
25+
26+
// Create a provider that blocks on ReadDataSource
27+
p := TestLocalProvider(t, b, "test", schema)
28+
29+
// We need to make sure ReadDataSource is called and blocks
30+
readCalled := make(chan struct{})
31+
block := make(chan struct{})
32+
33+
// Override the ReadDataSourceFn
34+
p.ReadDataSourceFn = func(req providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
35+
close(readCalled)
36+
<-block
37+
return providers.ReadDataSourceResponse{
38+
State: req.Config,
39+
}
40+
}
41+
42+
op, configCleanup, done := testOperationApply(t, "./testdata/apply-stop")
43+
defer configCleanup()
44+
45+
// Create a context that we can cancel
46+
ctx, cancel := context.WithCancel(context.Background())
47+
48+
// Cancel immediately to simulate "early" interrupt
49+
cancel()
50+
51+
run, err := b.Operation(ctx, op)
52+
if err != nil {
53+
t.Fatalf("bad: %s", err)
54+
}
55+
56+
// Wait for result
57+
doneCh := make(chan struct{})
58+
go func() {
59+
<-run.Done()
60+
close(doneCh)
61+
}()
62+
63+
select {
64+
case <-doneCh:
65+
// Success (it returned)
66+
case <-readCalled:
67+
// It reached the provider! This means it didn't stop early.
68+
close(block) // Unblock to cleanup
69+
t.Fatal("Operation reached provider despite early cancellation")
70+
case <-time.After(5 * time.Second):
71+
t.Fatal("Operation timed out")
72+
}
73+
74+
if run.Result == backendrun.OperationSuccess {
75+
t.Fatal("Operation succeeded but should have been cancelled")
76+
}
77+
78+
if errOutput := done(t).Stderr(); errOutput != "" {
79+
// We expect some error output due to cancellation, but let's log it just in case
80+
t.Logf("error output:\n%s", errOutput)
81+
}
82+
}

internal/backend/local/backend_refresh.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ func (b *Local) opRefresh(
5555
return
5656
}
5757

58+
// Set the stop context so that the core context can check for early
59+
// cancellation.
60+
lr.Core.SetStopContext(stopCtx)
61+
5862
// the state was locked during successful context creation; unlock the state
5963
// when the operation completes
6064
defer func() {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
data "test_data" "foo" {
2+
}

internal/terraform/context.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,22 @@ type Context struct {
106106
runCond *sync.Cond
107107
runContext context.Context
108108
runContextCancel context.CancelFunc
109+
stopContext context.Context
109110
}
110111

111112
// (additional methods on Context can be found in context_*.go files.)
112113

114+
// SetStopContext sets a context that, when cancelled, will cause any
115+
// currently-running or future operation on this Context to be cancelled.
116+
//
117+
// This is an alternative to calling Stop(), for cases where the caller
118+
// already has a context representing the lifecycle of the operation.
119+
func (c *Context) SetStopContext(ctx context.Context) {
120+
c.l.Lock()
121+
defer c.l.Unlock()
122+
c.stopContext = ctx
123+
}
124+
113125
// NewContext creates a new Context structure.
114126
//
115127
// Once a Context is created, the caller must not access or mutate any of

internal/terraform/context_apply.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ func (c *Context) Apply(plan *plans.Plan, config *configs.Config, opts *ApplyOpt
9393
// to be nil even when the state isn't, if the apply didn't complete enough for
9494
// the evaluation scope to produce consistent results.
9595
func (c *Context) ApplyAndEval(plan *plans.Plan, config *configs.Config, opts *ApplyOpts) (*states.State, *lang.Scope, tfdiags.Diagnostics) {
96+
c.l.Lock()
97+
if c.stopContext != nil && c.stopContext.Err() != nil {
98+
c.l.Unlock()
99+
return nil, nil, nil
100+
}
101+
c.l.Unlock()
102+
96103
defer c.acquireRun("apply")()
97104
var diags tfdiags.Diagnostics
98105

internal/terraform/context_import.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ type ImportTarget struct {
4949
// an import there is a failure, all previously imported resources remain
5050
// imported.
5151
func (c *Context) Import(config *configs.Config, prevRunState *states.State, opts *ImportOpts) (*states.State, tfdiags.Diagnostics) {
52+
c.l.Lock()
53+
if c.stopContext != nil && c.stopContext.Err() != nil {
54+
c.l.Unlock()
55+
return nil, nil
56+
}
57+
c.l.Unlock()
58+
5259
var diags tfdiags.Diagnostics
5360

5461
// Hold a lock since we can modify our own state here

internal/terraform/context_validate.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ type ValidateOpts struct {
6060
//
6161
// The opts can be nil, and the ExternalProviders field of the opts can be nil.
6262
func (c *Context) Validate(config *configs.Config, opts *ValidateOpts) tfdiags.Diagnostics {
63+
c.l.Lock()
64+
if c.stopContext != nil && c.stopContext.Err() != nil {
65+
c.l.Unlock()
66+
return nil
67+
}
68+
c.l.Unlock()
69+
6370
defer c.acquireRun("validate")()
6471

6572
var diags tfdiags.Diagnostics

internal/terraform/context_walk.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,13 @@ func (c *Context) graphWalker(graph *Graph, operation walkOperation, opts *graph
180180
deferred.SetExternalDependencyDeferred()
181181
}
182182

183+
c.l.Lock()
184+
stopCtx := c.stopContext
185+
c.l.Unlock()
186+
if stopCtx == nil {
187+
stopCtx = c.runContext
188+
}
189+
183190
return &ContextGraphWalker{
184191
Context: c,
185192
State: state,
@@ -196,7 +203,7 @@ func (c *Context) graphWalker(graph *Graph, operation walkOperation, opts *graph
196203
ExternalProviderConfigs: opts.ExternalProviderConfigs,
197204
MoveResults: opts.MoveResults,
198205
Operation: operation,
199-
StopContext: c.runContext,
206+
StopContext: stopCtx,
200207
PlanTimestamp: opts.PlanTimeTimestamp,
201208
functionResults: opts.FunctionResults,
202209
Forget: opts.Forget,

0 commit comments

Comments
 (0)