diff --git a/internal/cli/compose_up.go b/internal/cli/compose_up.go index e1bcc04..4573cc5 100644 --- a/internal/cli/compose_up.go +++ b/internal/cli/compose_up.go @@ -469,9 +469,9 @@ func (r *upRunner) buildAndExtendCompose(ctx context.Context, s composeStart, pe if opts.omitSyntaxDirective { syntax = "" } - dstFolder = filepath.Join(cacheFolder(), "container-features", fmt.Sprintf("%s-%d", cliVersion(), time.Now().UnixMilli())) - if err := os.MkdirAll(dstFolder, 0o755); err != nil { - return nil, fmt.Errorf("create features folder: %w", err) + var err error + if dstFolder, err = createFeaturesFolder(); err != nil { + return nil, err } info = imagemeta.GenerateComposeLabelBuild(baseName, labelMetadata, syntax) default: diff --git a/internal/cli/feature_install.go b/internal/cli/feature_install.go index 8b09244..d0b814f 100644 --- a/internal/cli/feature_install.go +++ b/internal/cli/feature_install.go @@ -127,12 +127,25 @@ func (r localFeatureRoots) resolve(logger log.Logger, id string) (string, bool, // createFeaturesFolder creates the folder the Features are staged in, TS // createFeaturesTempFolder: /container-features/-. +// The folder is this run's alone: runs started in the same millisecond would +// otherwise share it, and one run's realignFeatureDirs renames and removes the +// other's .stage- folders. A name another run holds moves to the next +// millisecond. func createFeaturesFolder() (string, error) { - dir := filepath.Join(cacheFolder(), "container-features", fmt.Sprintf("%s-%d", cliVersion(), time.Now().UnixMilli())) - if err := os.MkdirAll(dir, 0o755); err != nil { + parent := filepath.Join(cacheFolder(), "container-features") + if err := os.MkdirAll(parent, 0o755); err != nil { return "", fmt.Errorf("create features folder: %w", err) } - return dir, nil + for ms := time.Now().UnixMilli(); ; ms++ { + dir := filepath.Join(parent, fmt.Sprintf("%s-%d", cliVersion(), ms)) + err := os.Mkdir(dir, 0o755) + if err == nil { + return dir, nil + } + if !errors.Is(err, os.ErrExist) { + return "", fmt.Errorf("create features folder: %w", err) + } + } } // fetchFeatureSetsWithOrder resolves the feature dependency graph through the diff --git a/internal/cli/feature_install_test.go b/internal/cli/feature_install_test.go index 8f19669..08716e4 100644 --- a/internal/cli/feature_install_test.go +++ b/internal/cli/feature_install_test.go @@ -460,3 +460,22 @@ func TestReadConfigurationRefusesALocalFeatureOutsideDevcontainer(t *testing.T) } wantExitCode(t, err, 1) } + +// Runs started in the same millisecond each get a features folder of their +// own: a shared one let one run's realignFeatureDirs remove the other's +// .stage- folders ("stage feature h_0: lstat .../.stage-0: no such file or +// directory" in the parity lane). +func TestCreateFeaturesFolderIsTheRunsAlone(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + seen := map[string]bool{} + for range 50 { + dir, err := createFeaturesFolder() + if err != nil { + t.Fatal(err) + } + if seen[dir] { + t.Fatalf("%s handed out twice", dir) + } + seen[dir] = true + } +}