Skip to content

Commit cff3996

Browse files
aeneasrclaude
andauthored
fix: force-remove containers on pool cleanup and add ref-counting tests (#654)
Pool.cleanup() previously called resource.Close() which respects reference counting, leaving reused containers running when the pool shuts down. Now cleanup bypasses ref counting and force-removes all tracked containers. Adds 5 tests covering the cleanup API and ref-counting behavior: - TestResourceCloseRefCounting (integration) - TestPoolCloseT (integration) - TestPoolCleanupForceRemovesReusedContainers (integration) - TestCheckForExistingIncrementsRefCount (unit) - TestGetWithScopeDoesNotIncrementRefs (unit) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aed9230 commit cff3996

6 files changed

Lines changed: 393 additions & 24 deletions

File tree

pool.go

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ func NewPoolT(t TestingTB, endpoint string, opts ...PoolOption) *Pool {
160160
}
161161

162162
t.Cleanup(func() {
163-
if err := pool.Close(t.Context()); err != nil {
163+
if err := pool.Close(context.WithoutCancel(t.Context())); err != nil {
164164
t.Logf("pool.Close() error: %v", err)
165165
}
166166
})
@@ -186,18 +186,31 @@ func (p *Pool) Close(ctx context.Context) error {
186186
return cleanupErr
187187
}
188188

189+
// CloseT cleans up all tracked containers and networks, then closes the Pool's
190+
// Docker client. It calls t.Fatalf on error.
191+
func (p *Pool) CloseT(t TestingTB) {
192+
t.Helper()
193+
if err := p.Close(context.WithoutCancel(t.Context())); err != nil {
194+
t.Fatalf("Pool.CloseT failed: %v", err)
195+
}
196+
}
197+
189198
// cleanup removes all containers and networks tracked by this pool.
190199
// Containers are removed first, then networks. Errors during cleanup
191200
// do not stop the cleanup process. The first error encountered is returned.
201+
//
202+
// Unlike Resource.Close, cleanup force-removes containers regardless of
203+
// reference count. This ensures that Pool.Close fully cleans up even when
204+
// reused containers still have outstanding references.
192205
func (p *Pool) cleanup(ctx context.Context) error {
193206
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
194207
defer cancel()
195208

196209
var firstErr error
197210

198-
// Remove containers first (they may be connected to tracked networks)
211+
// Force-remove containers (bypass ref counting — the pool is shutting down).
199212
for _, resource := range p.trackedResources() {
200-
if err := resource.Close(cleanupCtx); err != nil && firstErr == nil {
213+
if err := p.forceRemoveContainer(cleanupCtx, resource.Container.ID); err != nil && firstErr == nil {
201214
firstErr = err
202215
}
203216
p.untrackResource(resource.Container.ID)
@@ -218,13 +231,26 @@ func (p *Pool) cleanup(ctx context.Context) error {
218231
return firstErr
219232
}
220233

234+
// forceRemoveContainer stops and removes a container, ignoring not-found errors.
235+
func (p *Pool) forceRemoveContainer(ctx context.Context, containerID string) error {
236+
_, _ = p.client.ContainerStop(ctx, containerID, mobyclient.ContainerStopOptions{})
237+
_, err := p.client.ContainerRemove(ctx, containerID, mobyclient.ContainerRemoveOptions{
238+
RemoveVolumes: true,
239+
Force: true,
240+
})
241+
if err != nil && !errdefs.IsNotFound(err) {
242+
return err
243+
}
244+
return nil
245+
}
246+
221247
// Run starts a container with the given repository and options.
222248
//
223249
// By default, containers are reused based on repository:tag to speed up tests.
224-
// This means that two calls with the same repository and tag will return the
225-
// same container, even if other options (env, cmd, etc.) differ. To ensure
226-
// a fresh container, use WithoutReuse(). To control reuse with a custom key
227-
// that accounts for your configuration, use WithReuseID().
250+
// Reused containers are reference-counted: the Docker container is only removed
251+
// when the last caller closes its reference. To ensure a fresh container, use
252+
// WithoutReuse(). To control reuse with a custom key that accounts for your
253+
// configuration, use WithReuseID().
228254
//
229255
// Example:
230256
//
@@ -302,15 +328,16 @@ func computeReuseID(repository string, cfg *runConfig) string {
302328
return fmt.Sprintf("%s:%s", repository, cfg.tag)
303329
}
304330

305-
// checkForExisting looks up an existing container in the registry.
306-
// The returned Resource is a shallow clone of the canonical registry entry
307-
// with the pool pointer updated to the caller's pool. This is safe because
308-
// Container (a value type) is copied, and reuseID is an immutable string.
331+
// checkForExisting looks up an existing container in the registry and
332+
// increments its reference count. The returned Resource is a shallow clone
333+
// of the canonical registry entry with the pool pointer updated to the
334+
// caller's pool. This is safe because Container (a value type) is copied,
335+
// and reuseID is an immutable string.
309336
func checkForExisting(p *Pool, reuseID string) *Resource {
310337
if reuseID == "" {
311338
return nil
312339
}
313-
if existing, ok := getWithScope(p.reuseScope, reuseID); ok {
340+
if existing, ok := acquireWithScope(p.reuseScope, reuseID); ok {
314341
cloned := *existing
315342
cloned.pool = p
316343
return &cloned

pool_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,112 @@ func TestCustomClientPoolHasIsolatedScope(t *testing.T) {
243243
}
244244
}
245245

246+
func TestPoolCloseT(t *testing.T) {
247+
if testing.Short() {
248+
t.Skip("Skipping integration test in short mode")
249+
}
250+
251+
ResetRegistry()
252+
t.Cleanup(func() {
253+
ResetRegistry()
254+
})
255+
256+
pool := NewPoolT(t, "")
257+
resource := pool.RunT(t, "alpine",
258+
WithTag("latest"),
259+
WithCmd([]string{"sleep", "300"}),
260+
WithoutReuse(),
261+
)
262+
containerID := resource.ID()
263+
264+
pool.CloseT(t)
265+
266+
// After CloseT, the container should be removed
267+
newPool, err := NewPool(t.Context(), "")
268+
if err != nil {
269+
t.Fatalf("NewPool() error = %v", err)
270+
}
271+
t.Cleanup(func() { newPool.Close(t.Context()) })
272+
273+
_, inspectErr := newPool.client.ContainerInspect(t.Context(), containerID, mobyclient.ContainerInspectOptions{})
274+
if !errdefs.IsNotFound(inspectErr) {
275+
t.Fatalf("ContainerInspect() error = %v, want not found", inspectErr)
276+
}
277+
}
278+
279+
func TestCheckForExistingIncrementsRefCount(t *testing.T) {
280+
ResetRegistry()
281+
282+
resource := &Resource{Container: container.InspectResponse{ID: "ref-count-check"}}
283+
// Register: refs=1
284+
registerWithScope("scope", "id", resource)
285+
286+
pool := &Pool{reuseScope: "scope"}
287+
// checkForExisting calls acquireWithScope: refs=2
288+
got := checkForExisting(pool, "id")
289+
if got == nil || got.ID() != resource.ID() {
290+
t.Fatalf("checkForExisting returned %v, want resource %q", got, resource.ID())
291+
}
292+
293+
// First release: refs=1, not last
294+
if releaseWithScope("scope", "id") {
295+
t.Fatal("first releaseWithScope was last, want false")
296+
}
297+
298+
// Second release: refs=0, last
299+
if !releaseWithScope("scope", "id") {
300+
t.Fatal("second releaseWithScope was not last, want true")
301+
}
302+
}
303+
304+
func TestPoolCleanupForceRemovesReusedContainers(t *testing.T) {
305+
if testing.Short() {
306+
t.Skip("Skipping integration test in short mode")
307+
}
308+
309+
ResetRegistry()
310+
t.Cleanup(func() {
311+
ResetRegistry()
312+
})
313+
314+
pool := NewPoolT(t, "")
315+
316+
// Run first reused container: refs=1
317+
r1 := pool.RunT(t, "alpine",
318+
WithTag("latest"),
319+
WithCmd([]string{"sleep", "300"}),
320+
)
321+
322+
// Run second reused container (same repo:tag): refs=2
323+
r2 := pool.RunT(t, "alpine",
324+
WithTag("latest"),
325+
WithCmd([]string{"sleep", "300"}),
326+
)
327+
328+
if r1.ID() != r2.ID() {
329+
t.Fatalf("expected same container ID, got %s and %s", r1.ID(), r2.ID())
330+
}
331+
332+
containerID := r1.ID()
333+
reuseScope := pool.reuseScope
334+
335+
// cleanup() should force-remove everything regardless of ref count
336+
if err := pool.cleanup(t.Context()); err != nil {
337+
t.Fatalf("cleanup() error = %v", err)
338+
}
339+
340+
// Container should be gone from Docker
341+
_, err := pool.client.ContainerInspect(t.Context(), containerID, mobyclient.ContainerInspectOptions{})
342+
if !errdefs.IsNotFound(err) {
343+
t.Fatalf("ContainerInspect() error = %v, want not found", err)
344+
}
345+
346+
// Registry entry should be cleared
347+
if _, ok := getWithScope(reuseScope, "alpine:latest"); ok {
348+
t.Fatal("registry entry still present after cleanup, want it removed")
349+
}
350+
}
351+
246352
func TestPoolCleanupRemovesWithoutReuse(t *testing.T) {
247353
if testing.Short() {
248354
t.Skip("Skipping integration test in short mode")

registry.go

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package dockertest
66
import (
77
"fmt"
88
"sync"
9+
"sync/atomic"
910

1011
"github.com/moby/moby/api/types/container"
1112
)
@@ -29,6 +30,15 @@ type registryKey struct {
2930
reuseID string
3031
}
3132

33+
// registryEntry wraps a Resource with an atomic reference count.
34+
// When multiple callers share a reused container, the ref count tracks
35+
// how many are still using it. The container is only removed from Docker
36+
// when the last reference is released.
37+
type registryEntry struct {
38+
resource *Resource
39+
refs atomic.Int32
40+
}
41+
3242
const defaultRegistryScope = "default"
3343

3444
// globalRegistry is the package-level container registry using sync.Map for thread-safety.
@@ -87,28 +97,71 @@ func GetAll() []*Resource {
8797

8898
func registerWithScope(scope, reuseID string, r *Resource) (*Resource, bool) {
8999
key := registryKey{scope: scope, reuseID: reuseID}
90-
actual, loaded := globalRegistry.LoadOrStore(key, r)
91-
resource, ok := actual.(*Resource)
100+
entry := &registryEntry{resource: r}
101+
entry.refs.Store(1)
102+
actual, loaded := globalRegistry.LoadOrStore(key, entry)
103+
existing, ok := actual.(*registryEntry)
92104
if !ok {
93105
return r, loaded
94106
}
95-
return resource, loaded
107+
if loaded {
108+
existing.refs.Add(1)
109+
return existing.resource, true
110+
}
111+
return existing.resource, false
96112
}
97113

98-
func unregisterWithScope(scope, reuseID string) {
99-
globalRegistry.Delete(registryKey{scope: scope, reuseID: reuseID})
114+
// acquireWithScope looks up an existing entry and increments its ref count.
115+
// Returns the resource and true if found, nil and false otherwise.
116+
func acquireWithScope(scope, reuseID string) (*Resource, bool) {
117+
key := registryKey{scope: scope, reuseID: reuseID}
118+
val, ok := globalRegistry.Load(key)
119+
if !ok {
120+
return nil, false
121+
}
122+
entry, ok := val.(*registryEntry)
123+
if !ok {
124+
return nil, false
125+
}
126+
entry.refs.Add(1)
127+
// Verify the entry is still in the map (not deleted and replaced between Load and Add).
128+
if current, ok := globalRegistry.Load(key); !ok || current != val {
129+
entry.refs.Add(-1)
130+
return nil, false
131+
}
132+
return entry.resource, true
133+
}
134+
135+
// releaseWithScope decrements the reference count for the given reuseID.
136+
// Returns true if this was the last reference (caller should remove the container).
137+
func releaseWithScope(scope, reuseID string) bool {
138+
key := registryKey{scope: scope, reuseID: reuseID}
139+
val, ok := globalRegistry.Load(key)
140+
if !ok {
141+
return true // Not found, treat as last reference
142+
}
143+
entry, ok := val.(*registryEntry)
144+
if !ok {
145+
globalRegistry.Delete(key)
146+
return true
147+
}
148+
if entry.refs.Add(-1) <= 0 {
149+
globalRegistry.Delete(key)
150+
return true
151+
}
152+
return false
100153
}
101154

102155
func getWithScope(scope, reuseID string) (*Resource, bool) {
103156
val, ok := globalRegistry.Load(registryKey{scope: scope, reuseID: reuseID})
104157
if !ok {
105158
return nil, false
106159
}
107-
resource, ok := val.(*Resource)
160+
entry, ok := val.(*registryEntry)
108161
if !ok {
109162
return nil, false
110163
}
111-
return resource, true
164+
return entry.resource, true
112165
}
113166

114167
func getAllWithScope(scope string) []*Resource {
@@ -119,8 +172,8 @@ func getAllWithScope(scope string) []*Resource {
119172
if !ok || regKey.scope != scope {
120173
return true
121174
}
122-
if resource, ok := value.(*Resource); ok {
123-
resources = append(resources, resource)
175+
if entry, ok := value.(*registryEntry); ok {
176+
resources = append(resources, entry.resource)
124177
}
125178
return true
126179
})

0 commit comments

Comments
 (0)