Skip to content

Commit e0475c1

Browse files
committed
fix: recreate reaper when the existing container is not running
1 parent 0103b91 commit e0475c1

2 files changed

Lines changed: 98 additions & 1 deletion

File tree

reaper.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,9 @@ func (r *reaperSpawner) cleanupLocked() error {
154154
// lookupContainer returns a DockerContainer type with the reaper container in the case
155155
// it's found in the running state, and including the labels for sessionID, reaper, and ryuk.
156156
// It will perform a retry with exponential backoff to allow for the container to be started and
157-
// avoid potential false negatives.
157+
// avoid potential false negatives. A container found in a stopped state is removed and
158+
// reported as errReaperNotFound, so the caller creates a new reaper instead of waiting
159+
// on a container that will never become ready.
158160
func (r *reaperSpawner) lookupContainer(ctx context.Context, sessionID string) (*DockerContainer, error) {
159161
dockerClient, err := NewDockerClientWithOpts(ctx)
160162
if err != nil {
@@ -194,6 +196,25 @@ func (r *reaperSpawner) lookupContainer(ctx context.Context, sessionID string) (
194196
return nil, fmt.Errorf("found %d reaper containers for session ID %q", len(resp.Items), sessionID)
195197
}
196198

199+
switch state := resp.Items[0].State; state {
200+
case container.StateRunning:
201+
// Continue below and return the container for reuse.
202+
case container.StateCreated, container.StateRestarting:
203+
// The container is on its way up, retry until it is running.
204+
return nil, fmt.Errorf("container not running: state %s", state)
205+
default:
206+
// Exited, dead, paused or removing: the reaper shuts itself down
207+
// once it has had no clients for its reconnection timeout, so a
208+
// stopped container will never become ready again. Remove what is
209+
// left of it so a new reaper can be created under the same name.
210+
// Auto-removed containers may already be gone, which is fine.
211+
if _, err := dockerClient.ContainerRemove(ctx, resp.Items[0].ID, client.ContainerRemoveOptions{Force: true}); err != nil && !errdefs.IsNotFound(err) {
212+
return nil, fmt.Errorf("remove stopped container: %w", err)
213+
}
214+
215+
return nil, backoff.Permanent(errReaperNotFound)
216+
}
217+
197218
r, err := provider.ContainerFromType(ctx, resp.Items[0])
198219
if err != nil {
199220
return nil, fmt.Errorf("from docker: %w", err)
@@ -344,6 +365,14 @@ func (r *reaperSpawner) reuseOrCreate(ctx context.Context, sessionID string, pro
344365
func (r *reaperSpawner) fromContainer(ctx context.Context, sessionID string, provider ReaperProvider, dockerContainer *DockerContainer) (*Reaper, error) {
345366
log.Printf("⏳ Waiting for Reaper %q to be ready", dockerContainer.ID[:8])
346367

368+
// The reaper might have terminated between being looked up and now, e.g.
369+
// because it reached its reconnection timeout with no clients. Waiting on
370+
// a stopped container would take the full startup timeout, so check first
371+
// and report not-found, which triggers a retry that recreates the reaper.
372+
if err := r.isRunning(ctx, dockerContainer); err != nil {
373+
return nil, err
374+
}
375+
347376
// Reusing an existing container so we determine the port from the container's exposed ports.
348377
if err := wait.ForAll(
349378
wait.ForLog("Started"),

reaper_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/containerd/errdefs"
1717
"github.com/moby/moby/api/types/container"
1818
"github.com/moby/moby/api/types/network"
19+
"github.com/moby/moby/client"
1920
"github.com/stretchr/testify/require"
2021

2122
"github.com/testcontainers/testcontainers-go/internal/config"
@@ -439,6 +440,73 @@ func Test_RecreateReaperIfTerminated(t *testing.T) {
439440
require.NoError(t, err, "connecting to Reaper should be successful")
440441
}
441442

443+
// Test_RecreateReaperIfStopped tests that a reaper container which still exists
444+
// but is no longer running, e.g. it shut down after its reconnection timeout
445+
// with no clients but was not removed yet, is replaced instead of being waited
446+
// on until the startup timeout expires.
447+
func Test_RecreateReaperIfStopped(t *testing.T) {
448+
reaperDisable(t, false)
449+
450+
SkipIfProviderIsNotHealthy(t)
451+
452+
ctx := context.Background()
453+
454+
provider, err := NewDockerProvider()
455+
require.NoError(t, err)
456+
457+
// Create a stopped container that the lookup identifies as the session's
458+
// reaper: same name and labels, but exited and not auto-removed.
459+
require.NoError(t, provider.PullImage(ctx, alpineImage))
460+
461+
labels := core.DefaultLabels(testSessionID)
462+
labels[core.LabelReaper] = "true"
463+
labels[core.LabelRyuk] = "true"
464+
delete(labels, core.LabelReap)
465+
466+
cli := provider.Client()
467+
created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{
468+
Config: &container.Config{
469+
Image: alpineImage,
470+
Cmd: []string{"true"},
471+
Labels: labels,
472+
},
473+
Name: reaperContainerNameFromSessionID(testSessionID),
474+
})
475+
require.NoError(t, err)
476+
t.Cleanup(func() {
477+
if _, err := cli.ContainerRemove(context.Background(), created.ID, client.ContainerRemoveOptions{Force: true}); err != nil && !errdefs.IsNotFound(err) {
478+
require.NoError(t, err)
479+
}
480+
})
481+
482+
_, err = cli.ContainerStart(ctx, created.ID, client.ContainerStartOptions{})
483+
require.NoError(t, err)
484+
485+
require.Eventually(t, func() bool {
486+
inspect, err := cli.ContainerInspect(ctx, created.ID, client.ContainerInspectOptions{})
487+
return err == nil && !inspect.Container.State.Running
488+
}, time.Second*10, time.Millisecond*100, "stopped reaper container should have exited")
489+
490+
// The stopped container must be replaced by a fresh reaper well within
491+
// the startup timeout that waiting on it for readiness would burn.
492+
timeout, cancel := context.WithTimeout(ctx, time.Second*30)
493+
defer cancel()
494+
495+
spawner := &reaperSpawner{}
496+
reaper, err := spawner.reaper(context.WithValue(timeout, core.DockerHostContextKey, provider.host), testSessionID, provider)
497+
cleanupReaper(t, reaper, spawner)
498+
require.NoError(t, err, "creating the Reaper should not error")
499+
require.NotEqual(t, created.ID, reaper.container.GetContainerID(), "expected a new reaper container")
500+
501+
// The stopped container was removed to free up the reaper name.
502+
_, err = cli.ContainerInspect(ctx, created.ID, client.ContainerInspectOptions{})
503+
require.True(t, errdefs.IsNotFound(err), "stopped reaper container should have been removed, got: %v", err)
504+
505+
termSignal, err := reaper.Connect()
506+
cleanupTermSignal(t, termSignal)
507+
require.NoError(t, err, "connecting to Reaper should be successful")
508+
}
509+
442510
func TestReaper_reuseItFromOtherTestProgramUsingDocker(t *testing.T) {
443511
reaperDisable(t, false)
444512

0 commit comments

Comments
 (0)