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:
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.
Summary
@turbo/genbundles the generator config to a path derived solely from the config file's owndirectory, imports it, then unlinks it in a
finallyimmediately after loading. The path has no pid,hash or temp component, and there is no lock, refcount or ownership check — so two concurrent
turbo geninvocations in one workspace compute the same file, and one deletes it while the otheris still reading it.
Only custom generators are involved — the bundling lives in
utils/plop.ts, whose sole consumer is thecustom-generator path, so
turbo gen workspacenever 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 chosenanything. 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/gen2.10.2, 2.10.9 (latest) and 2.10.10-canary.3 (canary) —src/utils/plop.tsisbyte-identical in all three, and on
mainSymptoms
Two surfaces, which look like unrelated bugs.
1 — names the bundle:
(also seen as
ERR_MODULE_NOT_FOUNDfor the same path)2 — names nothing. The bundle is imported mid-write,
requirereturns a malformed or empty module,discovery yields an empty list, and plop reports:
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 fileis byte-identical at 2.10.2, at 2.10.10-canary.3 (
canary) and onmain, so none of it isversion-specific. The bundle path is a pure function of the config file's location:
Cleanup then removes every registered path, unconditionally:
and it runs from a
finallyon both entry paths —getCustomGeneratorsandrunCustomGenerator,exactly two call sites:
(
createPlopFromConfigis what callsbundleConfigForLoading.)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:
fs.mkdtempSync()directory, orCleanup then removes only what that invocation created, which is what the
finallyalready intends. Alock 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):
Both surfaces appear within a few attempts. Without the timer, N background
turbo genloops against athrowaway 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 thegenerators directory gives it its own bundle path. Two non-obvious constraints:
templateFilepaths resolve relative to the plopfile's directory.--configalso 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.