Browse code

client: Client.ContainerExport: 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 17:55:45
Showing 7 changed files
... ...
@@ -19,6 +19,8 @@ type ContainerExportResult interface {
19 19
 // ContainerExport retrieves the raw contents of a container
20 20
 // and returns them as an [io.ReadCloser]. It's up to the caller
21 21
 // to close the stream.
22
+//
23
+// The underlying [io.ReadCloser] is automatically closed if the context is canceled,
22 24
 func (cli *Client) ContainerExport(ctx context.Context, containerID string, options ContainerExportOptions) (ContainerExportResult, error) {
23 25
 	containerID, err := trimID("container", containerID)
24 26
 	if err != nil {
... ...
@@ -31,30 +33,15 @@ func (cli *Client) ContainerExport(ctx context.Context, containerID string, opti
31 31
 	}
32 32
 
33 33
 	return &containerExportResult{
34
-		body: resp.Body,
34
+		ReadCloser: newCancelReadCloser(ctx, resp.Body),
35 35
 	}, nil
36 36
 }
37 37
 
38 38
 type containerExportResult struct {
39
-	// body must be closed to avoid a resource leak
40
-	body io.ReadCloser
39
+	io.ReadCloser
41 40
 }
42 41
 
43 42
 var (
44 43
 	_ io.ReadCloser         = (*containerExportResult)(nil)
45 44
 	_ ContainerExportResult = (*containerExportResult)(nil)
46 45
 )
47
-
48
-func (r *containerExportResult) Read(p []byte) (int, error) {
49
-	if r == nil || r.body == nil {
50
-		return 0, io.EOF
51
-	}
52
-	return r.body.Read(p)
53
-}
54
-
55
-func (r *containerExportResult) Close() error {
56
-	if r == nil || r.body == nil {
57
-		return nil
58
-	}
59
-	return r.body.Close()
60
-}
... ...
@@ -1,7 +1,6 @@
1 1
 package client
2 2
 
3 3
 import (
4
-	"context"
5 4
 	"io"
6 5
 	"net/http"
7 6
 	"testing"
... ...
@@ -17,14 +16,14 @@ func TestContainerExportError(t *testing.T) {
17 17
 	)
18 18
 	assert.NilError(t, err)
19 19
 
20
-	_, err = client.ContainerExport(context.Background(), "nothing", ContainerExportOptions{})
20
+	_, err = client.ContainerExport(t.Context(), "nothing", ContainerExportOptions{})
21 21
 	assert.Check(t, is.ErrorType(err, cerrdefs.IsInternal))
22 22
 
23
-	_, err = client.ContainerExport(context.Background(), "", ContainerExportOptions{})
23
+	_, err = client.ContainerExport(t.Context(), "", ContainerExportOptions{})
24 24
 	assert.Check(t, is.ErrorType(err, cerrdefs.IsInvalidArgument))
25 25
 	assert.Check(t, is.ErrorContains(err, "value is empty"))
26 26
 
27
-	_, err = client.ContainerExport(context.Background(), "    ", ContainerExportOptions{})
27
+	_, err = client.ContainerExport(t.Context(), "    ", ContainerExportOptions{})
28 28
 	assert.Check(t, is.ErrorType(err, cerrdefs.IsInvalidArgument))
29 29
 	assert.Check(t, is.ErrorContains(err, "value is empty"))
30 30
 }
... ...
@@ -40,10 +39,10 @@ func TestContainerExport(t *testing.T) {
40 40
 		}),
41 41
 	)
42 42
 	assert.NilError(t, err)
43
-	body, err := client.ContainerExport(context.Background(), "container_id", ContainerExportOptions{})
43
+	res, err := client.ContainerExport(t.Context(), "container_id", ContainerExportOptions{})
44 44
 	assert.NilError(t, err)
45
-	defer body.Close()
46
-	content, err := io.ReadAll(body)
45
+	defer res.Close()
46
+	content, err := io.ReadAll(res)
47 47
 	assert.NilError(t, err)
48 48
 	assert.Check(t, is.Equal(string(content), "response"))
49 49
 }
... ...
@@ -103,11 +103,11 @@ func (s *DockerAPISuite) TestContainerAPIGetExport(c *testing.T) {
103 103
 	assert.NilError(c, err)
104 104
 	defer apiClient.Close()
105 105
 
106
-	body, err := apiClient.ContainerExport(testutil.GetContext(c), name, client.ContainerExportOptions{})
106
+	res, err := apiClient.ContainerExport(testutil.GetContext(c), name, client.ContainerExportOptions{})
107 107
 	assert.NilError(c, err)
108
-	defer body.Close()
108
+	defer res.Close()
109 109
 	found := false
110
-	for tarReader := tar.NewReader(body); ; {
110
+	for tarReader := tar.NewReader(res); ; {
111 111
 		h, err := tarReader.Next()
112 112
 		if errors.Is(err, io.EOF) {
113 113
 			break
... ...
@@ -71,6 +71,7 @@ func TestExportContainerAfterDaemonRestart(t *testing.T) {
71 71
 
72 72
 	d.Restart(t)
73 73
 
74
-	_, err := c.ContainerExport(ctx, ctrID, client.ContainerExportOptions{})
74
+	res, err := c.ContainerExport(ctx, ctrID, client.ContainerExportOptions{})
75 75
 	assert.NilError(t, err)
76
+	_ = res.Close()
76 77
 }
... ...
@@ -32,10 +32,10 @@ func TestNoOverlayfsWarningsAboutUndefinedBehaviors(t *testing.T) {
32 32
 			return err
33 33
 		}},
34 34
 		{name: "export", operation: func(*testing.T) error {
35
-			rc, err := apiClient.ContainerExport(ctx, cID, client.ContainerExportOptions{})
35
+			res, err := apiClient.ContainerExport(ctx, cID, client.ContainerExportOptions{})
36 36
 			if err == nil {
37
-				defer rc.Close()
38
-				_, err = io.Copy(io.Discard, rc)
37
+				_, err = io.Copy(io.Discard, res)
38
+				_ = res.Close()
39 39
 			}
40 40
 			return err
41 41
 		}},
... ...
@@ -48,8 +48,8 @@ func TestNoOverlayfsWarningsAboutUndefinedBehaviors(t *testing.T) {
48 48
 		{name: "cp from container", operation: func(*testing.T) error {
49 49
 			res, err := apiClient.CopyFromContainer(ctx, cID, client.CopyFromContainerOptions{SourcePath: "/file"})
50 50
 			if err == nil {
51
-				defer res.Content.Close()
52 51
 				_, err = io.Copy(io.Discard, res.Content)
52
+				_ = res.Content.Close()
53 53
 			}
54 54
 
55 55
 			return err
... ...
@@ -363,13 +363,13 @@ func TestAuthZPluginEnsureLoadImportWorking(t *testing.T) {
363 363
 
364 364
 	cID := container.Run(ctx, t, c)
365 365
 
366
-	responseReader, err := c.ContainerExport(ctx, cID, client.ContainerExportOptions{})
366
+	res, err := c.ContainerExport(ctx, cID, client.ContainerExportOptions{})
367 367
 	assert.NilError(t, err)
368
-	defer responseReader.Close()
368
+	defer func() { _ = res.Close() }()
369 369
 	file, err := os.Create(exportedImagePath)
370 370
 	assert.NilError(t, err)
371
-	defer file.Close()
372
-	_, err = io.Copy(file, responseReader)
371
+	defer func() { _ = file.Close() }()
372
+	_, err = io.Copy(file, res)
373 373
 	assert.NilError(t, err)
374 374
 
375 375
 	err = imageImport(ctx, c, exportedImagePath)
... ...
@@ -19,6 +19,8 @@ type ContainerExportResult interface {
19 19
 // ContainerExport retrieves the raw contents of a container
20 20
 // and returns them as an [io.ReadCloser]. It's up to the caller
21 21
 // to close the stream.
22
+//
23
+// The underlying [io.ReadCloser] is automatically closed if the context is canceled,
22 24
 func (cli *Client) ContainerExport(ctx context.Context, containerID string, options ContainerExportOptions) (ContainerExportResult, error) {
23 25
 	containerID, err := trimID("container", containerID)
24 26
 	if err != nil {
... ...
@@ -31,30 +33,15 @@ func (cli *Client) ContainerExport(ctx context.Context, containerID string, opti
31 31
 	}
32 32
 
33 33
 	return &containerExportResult{
34
-		body: resp.Body,
34
+		ReadCloser: newCancelReadCloser(ctx, resp.Body),
35 35
 	}, nil
36 36
 }
37 37
 
38 38
 type containerExportResult struct {
39
-	// body must be closed to avoid a resource leak
40
-	body io.ReadCloser
39
+	io.ReadCloser
41 40
 }
42 41
 
43 42
 var (
44 43
 	_ io.ReadCloser         = (*containerExportResult)(nil)
45 44
 	_ ContainerExportResult = (*containerExportResult)(nil)
46 45
 )
47
-
48
-func (r *containerExportResult) Read(p []byte) (int, error) {
49
-	if r == nil || r.body == nil {
50
-		return 0, io.EOF
51
-	}
52
-	return r.body.Read(p)
53
-}
54
-
55
-func (r *containerExportResult) Close() error {
56
-	if r == nil || r.body == nil {
57
-		return nil
58
-	}
59
-	return r.body.Close()
60
-}