Browse code

client: Client.ImageSave: close reader on context cancellation

Use a cancelReadCloser to automatically close the reader when the context
is cancelled. Consumers are still recommended to manually close the reader,
but the cancelReadCloser makes the Close idempotent.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>

Sebastiaan van Stijn authored on 2025/10/30 19:56:24
Showing 10 changed files
... ...
@@ -11,7 +11,8 @@ type ImageSaveResult interface {
11 11
 }
12 12
 
13 13
 // ImageSave retrieves one or more images from the docker host as an
14
-// [ImageSaveResult].
14
+// [ImageSaveResult]. Callers should close the reader, but the underlying
15
+// [io.ReadCloser] is automatically closed if the context is canceled,
15 16
 //
16 17
 // Platforms is an optional parameter that specifies the platforms to save
17 18
 // from the image. Passing a platform only has an effect if the input image
... ...
@@ -44,30 +45,15 @@ func (cli *Client) ImageSave(ctx context.Context, imageIDs []string, saveOpts ..
44 44
 		return nil, err
45 45
 	}
46 46
 	return &imageSaveResult{
47
-		body: resp.Body,
47
+		ReadCloser: newCancelReadCloser(ctx, resp.Body),
48 48
 	}, nil
49 49
 }
50 50
 
51 51
 type imageSaveResult struct {
52
-	// body must be closed to avoid a resource leak
53
-	body io.ReadCloser
52
+	io.ReadCloser
54 53
 }
55 54
 
56 55
 var (
57 56
 	_ io.ReadCloser   = (*imageSaveResult)(nil)
58 57
 	_ ImageSaveResult = (*imageSaveResult)(nil)
59 58
 )
60
-
61
-func (r *imageSaveResult) Read(p []byte) (int, error) {
62
-	if r == nil || r.body == nil {
63
-		return 0, io.EOF
64
-	}
65
-	return r.body.Read(p)
66
-}
67
-
68
-func (r *imageSaveResult) Close() error {
69
-	if r == nil || r.body == nil {
70
-		return nil
71
-	}
72
-	return r.body.Close()
73
-}
... ...
@@ -16,8 +16,11 @@ func (f imageSaveOptionFunc) Apply(o *imageSaveOpts) error {
16 16
 	return f(o)
17 17
 }
18 18
 
19
-// ImageSaveWithPlatforms sets the platforms to be saved from the image.
19
+// ImageSaveWithPlatforms sets the platforms to be saved from the image. It
20
+// produces an error if platforms are already set. This option only has an
21
+// effect if the input image is a multi-platform image.
20 22
 func ImageSaveWithPlatforms(platforms ...ocispec.Platform) ImageSaveOption {
23
+	// TODO(thaJeztah): verify the GoDoc; do we produce an error for a single-platform image without the given platform?
21 24
 	return imageSaveOptionFunc(func(opt *imageSaveOpts) error {
22 25
 		if opt.apiOptions.Platforms != nil {
23 26
 			return fmt.Errorf("platforms already set to %v", opt.apiOptions.Platforms)
... ...
@@ -1,7 +1,6 @@
1 1
 package client
2 2
 
3 3
 import (
4
-	"context"
5 4
 	"io"
6 5
 	"net/http"
7 6
 	"net/url"
... ...
@@ -17,7 +16,7 @@ func TestImageSaveError(t *testing.T) {
17 17
 	client, err := New(WithMockClient(errorMock(http.StatusInternalServerError, "Server error")))
18 18
 	assert.NilError(t, err)
19 19
 	armv64 := ocispec.Platform{Architecture: "arm64", OS: "linux", Variant: "v8"}
20
-	_, err = client.ImageSave(context.Background(), []string{"nothing"}, ImageSaveWithPlatforms(armv64))
20
+	_, err = client.ImageSave(t.Context(), []string{"nothing"}, ImageSaveWithPlatforms(armv64))
21 21
 	assert.Check(t, is.ErrorType(err, cerrdefs.IsInternal))
22 22
 }
23 23
 
... ...
@@ -69,7 +68,7 @@ func TestImageSave(t *testing.T) {
69 69
 				return mockResponse(http.StatusOK, nil, expectedOutput)(req)
70 70
 			}))
71 71
 			assert.NilError(t, err)
72
-			resp, err := client.ImageSave(context.Background(), []string{"image_id1", "image_id2"}, tc.options...)
72
+			resp, err := client.ImageSave(t.Context(), []string{"image_id1", "image_id2"}, tc.options...)
73 73
 			assert.NilError(t, err)
74 74
 			defer func() {
75 75
 				assert.NilError(t, resp.Close())
... ...
@@ -75,7 +75,7 @@ func TestBuildUserNamespaceValidateCapabilitiesAreV2(t *testing.T) {
75 75
 
76 76
 	reader, err := clientUserRemap.ImageSave(ctx, []string{imageTag})
77 77
 	assert.NilError(t, err, "failed to download capabilities image")
78
-	defer reader.Close()
78
+	defer func() { _ = reader.Close() }()
79 79
 
80 80
 	tar, err := os.Create(filepath.Join(tmpDir, "image.tar"))
81 81
 	assert.NilError(t, err, "failed to create image tar file")
... ...
@@ -130,8 +130,8 @@ func TestMigrateSaveLoad(t *testing.T) {
130 130
 	rdr, err := apiClient.ImageSave(ctx, []string{"busybox:latest"})
131 131
 	assert.NilError(t, err)
132 132
 	buf := bytes.NewBuffer(nil)
133
-	io.Copy(buf, rdr)
134
-	rdr.Close()
133
+	_, _ = io.Copy(buf, rdr)
134
+	defer func() { _ = rdr.Close() }()
135 135
 
136 136
 	// Delete all images
137 137
 	list, err := apiClient.ImageList(ctx, client.ImageListOptions{})
... ...
@@ -44,7 +44,7 @@ func tarIndexFS(t *testing.T, rdr io.Reader) fs.FS {
44 44
 	assert.NilError(t, err)
45 45
 
46 46
 	// Do not close at the end of this function otherwise the indexer won't work
47
-	t.Cleanup(func() { f.Close() })
47
+	t.Cleanup(func() { _ = f.Close() })
48 48
 
49 49
 	_, err = io.Copy(f, rdr)
50 50
 	assert.NilError(t, err)
... ...
@@ -64,6 +64,7 @@ func TestSaveCheckTimes(t *testing.T) {
64 64
 
65 65
 	rdr, err := apiClient.ImageSave(ctx, []string{repoName})
66 66
 	assert.NilError(t, err)
67
+	defer func() { _ = rdr.Close() }()
67 68
 
68 69
 	created, err := time.Parse(time.RFC3339, img.Created)
69 70
 	assert.NilError(t, err)
... ...
@@ -135,7 +136,7 @@ func TestSaveOCI(t *testing.T) {
135 135
 
136 136
 			rdr, err := apiClient.ImageSave(ctx, []string{tc.image})
137 137
 			assert.NilError(t, err)
138
-			defer rdr.Close()
138
+			defer func() { _ = rdr.Close() }()
139 139
 
140 140
 			tarfs := tarIndexFS(t, rdr)
141 141
 
... ...
@@ -171,7 +172,7 @@ func TestSaveOCI(t *testing.T) {
171 171
 					assert.NilError(t, err)
172 172
 
173 173
 					layerDigest, err := testutil.UncompressedTarDigest(f)
174
-					f.Close()
174
+					_ = f.Close()
175 175
 
176 176
 					assert.NilError(t, err)
177 177
 
... ...
@@ -426,7 +427,7 @@ func TestSaveRepoWithMultipleImages(t *testing.T) {
426 426
 
427 427
 	rdr, err := apiClient.ImageSave(ctx, []string{repoName, "busybox:latest"})
428 428
 	assert.NilError(t, err)
429
-	defer rdr.Close()
429
+	defer func() { _ = rdr.Close() }()
430 430
 
431 431
 	tarfs := tarIndexFS(t, rdr)
432 432
 
... ...
@@ -483,7 +484,7 @@ RUN touch /opt/a/b/c && chown user:user /opt/a/b/c`
483 483
 
484 484
 	rdr, err := apiClient.ImageSave(ctx, []string{imgID})
485 485
 	assert.NilError(t, err)
486
-	defer rdr.Close()
486
+	defer func() { _ = rdr.Close() }()
487 487
 
488 488
 	tarfs := tarIndexFS(t, rdr)
489 489
 
... ...
@@ -425,17 +425,17 @@ func TestAuthzPluginEnsureContainerCopyToFrom(t *testing.T) {
425 425
 }
426 426
 
427 427
 func imageSave(ctx context.Context, apiClient client.APIClient, path, imgRef string) error {
428
-	responseReader, err := apiClient.ImageSave(ctx, []string{imgRef})
428
+	resp, err := apiClient.ImageSave(ctx, []string{imgRef})
429 429
 	if err != nil {
430 430
 		return err
431 431
 	}
432
-	defer responseReader.Close()
432
+	defer func() { _ = resp.Close() }()
433 433
 	file, err := os.Create(path)
434 434
 	if err != nil {
435 435
 		return err
436 436
 	}
437
-	defer file.Close()
438
-	_, err = io.Copy(file, responseReader)
437
+	_, err = io.Copy(file, resp)
438
+	_ = file.Close()
439 439
 	return err
440 440
 }
441 441
 
... ...
@@ -879,7 +879,7 @@ func (d *Daemon) LoadImage(ctx context.Context, t testing.TB, img string) {
879 879
 
880 880
 	reader, err := clientHost.ImageSave(ctx, []string{img})
881 881
 	assert.NilError(t, err, "[%s] failed to download %s", d.id, img)
882
-	defer reader.Close()
882
+	defer func() { _ = reader.Close() }()
883 883
 
884 884
 	c := d.NewClientT(t)
885 885
 	defer c.Close()
... ...
@@ -11,7 +11,8 @@ type ImageSaveResult interface {
11 11
 }
12 12
 
13 13
 // ImageSave retrieves one or more images from the docker host as an
14
-// [ImageSaveResult].
14
+// [ImageSaveResult]. Callers should close the reader, but the underlying
15
+// [io.ReadCloser] is automatically closed if the context is canceled,
15 16
 //
16 17
 // Platforms is an optional parameter that specifies the platforms to save
17 18
 // from the image. Passing a platform only has an effect if the input image
... ...
@@ -44,30 +45,15 @@ func (cli *Client) ImageSave(ctx context.Context, imageIDs []string, saveOpts ..
44 44
 		return nil, err
45 45
 	}
46 46
 	return &imageSaveResult{
47
-		body: resp.Body,
47
+		ReadCloser: newCancelReadCloser(ctx, resp.Body),
48 48
 	}, nil
49 49
 }
50 50
 
51 51
 type imageSaveResult struct {
52
-	// body must be closed to avoid a resource leak
53
-	body io.ReadCloser
52
+	io.ReadCloser
54 53
 }
55 54
 
56 55
 var (
57 56
 	_ io.ReadCloser   = (*imageSaveResult)(nil)
58 57
 	_ ImageSaveResult = (*imageSaveResult)(nil)
59 58
 )
60
-
61
-func (r *imageSaveResult) Read(p []byte) (int, error) {
62
-	if r == nil || r.body == nil {
63
-		return 0, io.EOF
64
-	}
65
-	return r.body.Read(p)
66
-}
67
-
68
-func (r *imageSaveResult) Close() error {
69
-	if r == nil || r.body == nil {
70
-		return nil
71
-	}
72
-	return r.body.Close()
73
-}
... ...
@@ -16,8 +16,11 @@ func (f imageSaveOptionFunc) Apply(o *imageSaveOpts) error {
16 16
 	return f(o)
17 17
 }
18 18
 
19
-// ImageSaveWithPlatforms sets the platforms to be saved from the image.
19
+// ImageSaveWithPlatforms sets the platforms to be saved from the image. It
20
+// produces an error if platforms are already set. This option only has an
21
+// effect if the input image is a multi-platform image.
20 22
 func ImageSaveWithPlatforms(platforms ...ocispec.Platform) ImageSaveOption {
23
+	// TODO(thaJeztah): verify the GoDoc; do we produce an error for a single-platform image without the given platform?
21 24
 	return imageSaveOptionFunc(func(opt *imageSaveOpts) error {
22 25
 		if opt.apiOptions.Platforms != nil {
23 26
 			return fmt.Errorf("platforms already set to %v", opt.apiOptions.Platforms)