Skip to content

Commit 79b63e1

Browse files
committed
fix: propagate context cancellation during image pull
On Ctrl+C mid-pull, the cancelled context made the rt.ImageExists local-image probe fail, so the start surfaced a misleading "Failed to pull" error and emitted a spurious start-error telemetry event. Guard the pull-failure path with ctx.Err() so a user cancel propagates cleanly, mirroring the existing license pre-flight handling. Also documents the known limitation that an HTTP error response from the license server (5xx, or 407 from a proxy) is still treated as a definitive verdict rather than degrading. Refs DEVX-703
1 parent 6e8a270 commit 79b63e1

2 files changed

Lines changed: 47 additions & 0 deletions

File tree

internal/container/start.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,12 @@ func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *
324324
}()
325325
if err := rt.PullImage(ctx, c.Image, progress); err != nil {
326326
sink.Emit(output.SpinnerStop())
327+
// A cancelled caller context (e.g. Ctrl+C) is not an offline condition —
328+
// propagate it instead of probing for a local image or reporting a pull
329+
// failure, which would also emit a spurious start-error telemetry event.
330+
if ctx.Err() != nil {
331+
return nil, ctx.Err()
332+
}
327333
// The registry may be unreachable (offline, proxy, or TLS interception in
328334
// enterprise networks). If the image is already available locally, fall back
329335
// to it instead of failing — the image carries its own license.
@@ -621,6 +627,10 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c
621627
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: "Could not reach the license server; continuing with the image's bundled license"})
622628
return nil
623629
}
630+
// Known limitation: any *api.LicenseError — i.e. any non-200 HTTP response,
631+
// including a 5xx or a 407 from a corporate proxy — is treated as a definitive
632+
// verdict and stays fatal here; only connection-level failures (handled above)
633+
// degrade. Gating this on licErr.Status is tracked as follow-up.
624634
if licErr.Detail != "" {
625635
opts.Logger.Error("license server response (HTTP %d): %s", licErr.Status, licErr.Detail)
626636
}

internal/container/start_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,43 @@ func TestPullImages_FailsWhenPullFailsAndImageMissing(t *testing.T) {
443443
assert.Contains(t, out.String(), "Failed to pull localstack/localstack-pro:latest")
444444
}
445445

446+
func TestPullImages_PropagatesContextCancellation(t *testing.T) {
447+
// A cancelled caller context (Ctrl+C) during a pull must surface as
448+
// cancellation — not be reported as a pull failure nor probed as a local-image
449+
// fallback (which would also emit a spurious start-error telemetry event).
450+
ctrl := gomock.NewController(t)
451+
mockRT := runtime.NewMockRuntime(ctrl)
452+
453+
c := runtime.ContainerConfig{
454+
Image: "localstack/localstack-pro:latest",
455+
Name: "localstack-aws",
456+
EmulatorType: config.EmulatorAWS,
457+
}
458+
459+
ctx, cancel := context.WithCancel(context.Background())
460+
461+
mockRT.EXPECT().Remove(gomock.Any(), c.Name).Return(nil)
462+
mockRT.EXPECT().PullImage(gomock.Any(), c.Image, gomock.Any()).DoAndReturn(
463+
func(_ context.Context, _ string, progress chan<- runtime.PullProgress) error {
464+
cancel() // the user interrupts mid-pull
465+
close(progress)
466+
return context.Canceled
467+
})
468+
// ImageExists must NOT be called once the context is cancelled; gomock fails
469+
// the test if it is (no EXPECT registered).
470+
471+
var out bytes.Buffer
472+
sink := output.NewPlainSink(&out)
473+
tel := telemetry.New("", true)
474+
475+
_, err := pullImages(ctx, mockRT, sink, tel, []runtime.ContainerConfig{c})
476+
477+
require.ErrorIs(t, err, context.Canceled)
478+
assert.False(t, output.IsSilent(err), "a user cancel is not a silent start error")
479+
assert.NotContains(t, out.String(), "Failed to pull", "a cancel must not be reported as a pull failure")
480+
assert.NotContains(t, out.String(), "using the local image")
481+
}
482+
446483
func TestValidateLicense_ContinuesWhenServerUnreachable(t *testing.T) {
447484
// A closed port yields a transport-level failure (not an *api.LicenseError),
448485
// which models an offline/proxy environment that cannot reach the license server.

0 commit comments

Comments
 (0)