Skip to content

@turbo/gen: concurrent invocations race on one shared bundle path #13735

Description

@rdiaz-md

Summary

@turbo/gen bundles the generator config to a path derived solely from the config file's own
directory
, imports it, then unlinks it in a finally immediately after loading. The path has no pid,
hash or temp component, and there is no lock, refcount or ownership check — so two concurrent
turbo gen invocations in one workspace compute the same file, and one deletes it while the other
is still reading it.

Only custom generators are involved — the bundling lives in utils/plop.ts, whose sole consumer is the
custom-generator path, so turbo gen workspace never writes or deletes a bundle. Within that path,
though, the exposure is broader than it first looks. Discovery bundles as well as running does: a bare
turbo gen, which just lists what's available, writes and then deletes bundles before you have chosen
anything. And discovery walks every workspace in the project, bundling each config it finds and clearing
the whole set in one go — so an invocation working in one package can delete the bundle another
invocation is loading from a different package. The two racing commands need not be the same generator,
or even in the same workspace.

Environment

  • @turbo/gen 2.10.2, 2.10.9 (latest) and 2.10.10-canary.3 (canary) — src/utils/plop.ts is
    byte-identical in all three, and on main
  • pnpm workspace monorepo, macOS; concurrency from ordinary parallel test workers. No daemon, no watch

Symptoms

Two surfaces, which look like unrelated bugs.

1 — names the bundle:

Error - ENOENT: no such file or directory, open '<repo>/turbo/generators/config.turbo-gen-bundled.cjs'

(also seen as ERR_MODULE_NOT_FOUND for the same path)

2 — names nothing. The bundle is imported mid-write, require returns a malformed or empty module,
discovery yields an empty list, and plop reports:

No generators found.

sometimes preceded by TypeError: a is not a function.

Surface 2 is the costly one — it is indistinguishable from a genuinely broken generator config, so it
sends you hunting through a config file that is fine.

Root cause

All of this is in packages/turbo-gen/src/utils/plop.ts, quoted here from 2.10.9 (latest). That file
is byte-identical at 2.10.2, at 2.10.10-canary.3 (canary) and on main, so none of it is
version-specific. The bundle path is a pure function of the config file's location:

async function bundleConfigForLoading(configPath: string): Promise<string> {
  const outName = path
    .basename(configPath)
    .replace(/\.(ts|js|cjs|mts|mjs)$/, ".turbo-gen-bundled.cjs");
  const outDir = path.dirname(configPath);
  const outPath = path.join(outDir, outName); // <- no pid, hash or temp dir

  try {
    const result = await esbuild({
      entryPoints: [configPath],
      outfile: outPath,
      /* ...bundle/format/platform/nodePaths/plugins/footer... */
    });

    if (result.errors.length > 0) {
      return configPath;
    }

    bundled.add(outPath); // module-level `const bundled = new Set<string>()`
    return outPath;
  } catch {
    return configPath;
  }
}

Cleanup then removes every registered path, unconditionally:

function cleanupBundledConfigs() {
  for (const p of bundled) {
    try {
      fs.removeSync(p); // no lock, no refcount, no ownership check
    } catch {
      // ignore cleanup failures
    }
  }
  bundled.clear();
}

and it runs from a finally on both entry paths — getCustomGenerators and runCustomGenerator,
exactly two call sites:

// discovery
const configs = discoverGeneratorConfigs(project, configPath);
try {
  for (const conf of configs) {
    const plop = await createPlopFromConfig(conf.config, conf.root);
    // ...collect this config's generators...
  }
} finally {
  cleanupBundledConfigs();
}

// run
const resolvedConfigPath = configPath ?? generator.configPath;
const destBasePath = configPath ?? generator.destBasePath;

let plop: NodePlopAPI | undefined;
try {
  plop = await createPlopFromConfig(resolvedConfigPath, destBasePath);
} finally {
  cleanupBundledConfigs();
}

(createPlopFromConfig is what calls bundleConfigForLoading.)

So each invocation deletes the shared path the moment it finishes loading — exactly the window a
concurrent invocation is still reading it in.

Expected vs actual

Concurrent invocations should succeed independently; one finishing should not delete a file another is
loading. Instead, whichever reaches the cleanup first unlinks the shared bundle and the other fails
with surface 1 or 2, non-deterministically.

Proposed fix

Give each invocation its own bundle path, so there is no shared resource:

  • write into a per-invocation fs.mkdtempSync() directory, or
  • add a pid/random suffix to the filename.

Cleanup then removes only what that invocation created, which is what the finally already intends. A
lock or refcount would work too, but the bundle is regenerated every invocation — there is no reason
for it to be shared at all.

Reproduction

Probabilistic under natural concurrency, since two invocations must overlap inside the load-then-unlink
window — so the source walk above is the primary evidence, and is checkable without running anything.

It becomes deterministic by replaying the existing cleanup on a short timer while one generator runs
(same behaviour, shorter period — not synthetic interference):

// Terminal 1
const fs = require("node:fs");
setInterval(() => {
  try {
    fs.unlinkSync("turbo/generators/config.turbo-gen-bundled.cjs");
  } catch {}
}, 5);
# Terminal 2, in a workspace with a turbo/generators/config.ts
turbo gen <generator-name> --args <...>

Both surfaces appear within a few attempts. Without the timer, N background turbo gen loops against a
throwaway destination alongside a foreground invocation reproduce it at a lower rate — at concurrency 4
over 20 rounds we saw all three signatures.

Workaround

--config <path> is threaded through the run path, so pointing each invocation at its own copy of the
generators directory gives it its own bundle path. Two non-obvious constraints:

  • Copy the whole directory, not just the config file — configs usually import sibling helpers, and
    templateFile paths resolve relative to the plopfile's directory.
  • --config also overrides the destination base path (destBasePath = configPath ?? generator.destBasePath,
    above), so unless the destination root is set back explicitly, output lands next to the temporary
    copy instead of in the repository.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions