Compare commits

...

13 Commits

Author SHA1 Message Date
Craigory Coppola 0ae787c56f chore(testing): bump bust for uncached CI run 2026-06-03 16:07:19 -04:00
Craigory Coppola 59680f2e81 chore(testing): bump bust for uncached CI run 2026-06-03 16:07:19 -04:00
Craigory Coppola ec78f4b8d2 chore(testing): bump bust for uncached CI run 2026-06-03 16:07:19 -04:00
Craigory Coppola 8e0e0cb208 chore(testing): bump bust for round 3 uncached CI 2026-06-03 16:07:19 -04:00
Craigory Coppola 658f2a2f39 chore(testing): bump bust for fifth uncached CI run 2026-06-03 16:07:19 -04:00
Craigory Coppola acd6d08038 chore(testing): bump bust for fourth uncached CI run 2026-06-03 16:07:19 -04:00
Craigory Coppola da9ca8ec9a chore(testing): bump bust for third uncached CI run 2026-06-03 16:07:19 -04:00
Craigory Coppola 3d4b481775 chore(testing): bump bust for second uncached CI run 2026-06-03 16:07:19 -04:00
Craigory Coppola 0ac637cfbb chore(testing): bump bust to force uncached CI run
Forces e2e/nx (and other) tasks to re-run without cache hits so the
spread.test.ts stress loops actually exercise the daemon path under
the new fixes.
2026-06-03 16:07:19 -04:00
Craigory Coppola 95fda5ec98 chore(testing): mirror the flaky test shape in a stress loop
Cherry-picked from PR #35705 (Jason Jean). Adds a stress test that
matches the exact write shape of the original flake — plugin file +
nx.json (plugins + targetDefaults) + project.json all changing
together, then a single show project query — so CI reproduces the
race reliably instead of the 1-in-N single-shot test.

Pairs with the get-plugins dedupe and the kickOffRecompute try/catch
in this branch: this is the failing shape both fixes must cover.
2026-06-03 16:07:19 -04:00
Craigory Coppola 1e6e612ea8 fix(core): keep daemon alive when a recompute's plugin load fails
Cherry-picked from PR #35705 (Jason Jean). Wraps the kickOffRecompute
IIFE body in try/catch so a rejection from the prologue (readNxJson,
getPluginsSeparated, isStale) becomes an errorResult the next requester
surfaces instead of crashing the daemon with an unhandled rejection.

Orthogonal to the in-flight dedupe fix one commit ago — that addresses
a successful-but-stale plugin load returning the previous load's
SeparatedPlugins. This one addresses the load *rejecting* (e.g., a
plugin file failing to require). Both can be hit by the spread test
under tight write→query loops; the strongest branch ships both.
2026-06-03 16:07:19 -04:00
Craigory Coppola 9484a4d885 chore(testing): stress daemon watcher race in spread.test.ts
Adds three rapid-reconfiguration stress tests that mutate nx.json
and tools/* in tight loops with no settle time between writes and
`show project` queries — and without a `reset` in between — so the
long-lived daemon has to pick up every change through its watcher
before serving the graph. A stale graph surfaces immediately as a
previous iteration's input.

afterEach now dumps `.nx/workspace-data/d/daemon.log` BEFORE reset
(which rotates the file) so the [watcher]/recompute lines land in
CI stdout alongside any failing assertion.

Bumps `bust` so CI runs these e2e tests uncached against the new
plugin-cache fix.
2026-06-03 16:07:19 -04:00
Craigory Coppola d6ebf7e247 fix(core): dedupe in-flight getPluginsSeparated to avoid stale cache race
`getPluginsSeparated` updated `currentPluginsConfigurationHash` to the
NEW hash before its load completed, leaving a window where the cache
check `cachedSeparatedPlugins && hash === currentHash` passed but the
cache still held the previous load's result. A concurrent caller
landing in that window received stale `specifiedPlugins`.

In the daemon this surfaced as the spread.test.ts middle-plugin
flake: after the test wrote nx.json with three specified plugins,
the watcher-triggered recompute and the `show project` request both
called `getPluginsSeparated`. The second call hit the stale cache,
the project graph was built with no specified plugins, and `build`
target was missing on the queried project.

Track the in-flight load with its hash and dedupe same-hash concurrent
callers onto it. Only commit cache + hash AFTER the load completes
and only if we're still the latest in-flight load. Clear the marker
in a `finally` so a rejection doesn't poison subsequent retries.
2026-06-03 16:07:19 -04:00
5 changed files with 437 additions and 50 deletions
+218
View File
@@ -3,10 +3,13 @@ import {
newProject,
readJson,
runCLI,
tmpProjPath,
uniq,
updateFile,
updateJson,
} from '@nx/e2e-utils';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
describe('Spread Token Merging', () => {
let proj: string;
@@ -22,6 +25,32 @@ describe('Spread Token Merging', () => {
existingNxJson = readJson('nx.json');
});
afterEach(() => {
// Print the daemon log into the test's stdout BEFORE reset (which
// stops the daemon and may rotate the file). CI captures stdout
// per test so the [watcher] lines end up alongside the failure
// assertion in the build output.
try {
const daemonLog = join(
tmpProjPath(),
'.nx',
'workspace-data',
'd',
'daemon.log'
);
if (existsSync(daemonLog)) {
const contents = readFileSync(daemonLog, 'utf-8');
console.log(
`\n========== daemon.log for "${
expect.getState().currentTestName ?? 'unknown'
}" ==========\n${contents}\n========== end daemon.log ==========\n`
);
} else {
console.log(`[spread-debug] no daemon log at ${daemonLog}`);
}
} catch (e) {
console.log(`[spread-debug] failed to read daemon log: ${e}`);
}
updateFile('nx.json', JSON.stringify(existingNxJson, null, 2));
// Reset daemon cache so the next test does not see stale plugin-inferred
// project graph data. The PR enabling NX_DAEMON=true in runCLI means the
@@ -925,4 +954,193 @@ describe('Spread Token Merging', () => {
expect(project.targets.echo.dependsOn).toEqual(['prebuild', '^build']);
});
});
/**
* Race-condition stress.
*
* These tests deliberately mutate nx.json (and tools/*) in tight loops
* with no settle time between the write and the `show project` query,
* and without a `reset` in between — so the long-lived daemon must pick
* up every change through its file watcher before it serves the graph.
*
* If the daemon answers from a stale cached graph (the watcher event
* for the latest nx.json write has not landed yet), the resolved
* build.inputs will not match the expectation and the test fails.
* Each loop iteration is a fresh chance to hit that window, so a flake
* that shows up ~1-in-20 in a single-shot test shows up far more often
* here. The restored daemon.log dump (afterEach) captures the
* [watcher]/recompute lines so a failure is diagnosable on CI.
*/
describe('rapid reconfiguration (race-condition stress)', () => {
it('reflects the latest specified plugin after rapid nx.json swaps', () => {
const lib = uniq('lib');
runCLI(`generate @nx/js:lib libs/${lib}`);
updateJson(`libs/${lib}/project.json`, (c) => {
c.targets = {};
return c;
});
// Pre-create a pool of plugins, each stamping a distinct input.
const pluginCount = 6;
for (let i = 0; i < pluginCount; i++) {
createPlugin(
`race-plugin-${i}`,
`{
build: {
executor: 'nx:run-commands',
options: { command: 'echo build' },
inputs: ['from-plugin-${i}'],
}
}`
);
}
// Swap the active plugin and query immediately, no settle time.
// A stale daemon graph surfaces as the previous iteration's input.
for (let i = 0; i < pluginCount; i++) {
updateJson('nx.json', (json) => {
json.plugins = [`./tools/race-plugin-${i}`];
return json;
});
const project = getResolvedProject(lib);
expect(project.targets.build.inputs).toEqual([`from-plugin-${i}`]);
}
});
it('reflects plugin-list growth and shrink across rapid nx.json edits', () => {
const lib = uniq('lib');
runCLI(`generate @nx/js:lib libs/${lib}`);
updateJson(`libs/${lib}/project.json`, (c) => {
c.targets = {};
return c;
});
createPlugin(
'race-base',
`{
build: {
executor: 'nx:run-commands',
options: { command: 'echo build' },
inputs: ['base'],
}
}`
);
createPlugin(
'race-spread',
`{
build: {
executor: 'nx:run-commands',
options: { command: 'echo build' },
inputs: ['spread', '...'],
}
}`
);
// Alternate between one plugin and two (with spread). The resolved
// inputs differ each step, so a stale graph is caught immediately.
const steps: { plugins: string[]; inputs: string[] }[] = [
{ plugins: ['./tools/race-base'], inputs: ['base'] },
{
plugins: ['./tools/race-base', './tools/race-spread'],
inputs: ['spread', 'base'],
},
{ plugins: ['./tools/race-spread'], inputs: ['spread'] },
{
plugins: ['./tools/race-base', './tools/race-spread'],
inputs: ['spread', 'base'],
},
{ plugins: ['./tools/race-base'], inputs: ['base'] },
];
for (const { plugins, inputs } of steps) {
updateJson('nx.json', (json) => {
json.plugins = plugins;
return json;
});
const project = getResolvedProject(lib);
expect(project.targets.build.inputs).toEqual(inputs);
}
});
it('reflects rapid project.json edits against a stable plugin base', () => {
const lib = uniq('lib');
runCLI(`generate @nx/js:lib libs/${lib}`);
createPlugin(
'race-infer',
`{
build: {
executor: 'nx:run-commands',
options: { command: 'echo build' },
inputs: ['inferred'],
}
}`
);
updateJson('nx.json', (json) => {
json.plugins = ['./tools/race-infer'];
return json;
});
// Plugin set is fixed; only project.json changes each round. The
// daemon must observe that file change before answering — a stale
// graph surfaces as a previous iteration's project input.
for (let i = 0; i < 6; i++) {
updateJson(`libs/${lib}/project.json`, (c) => {
c.targets = {
build: {
inputs: [`project-${i}`, '...'],
},
};
return c;
});
const project = getResolvedProject(lib);
expect(project.targets.build.inputs).toEqual([
`project-${i}`,
'inferred',
]);
}
});
/**
* Mirrors the shape of the flaky single-shot test
* ("...with target defaults overriding"): per iteration a plugin
* file, nx.json (plugins + targetDefaults) and project.json all
* change together, then a single `show project` query. A stale
* daemon graph here surfaces as build being undefined entirely —
* the plugin set the graph was built against never ran.
*/
it('reflects a plugin + nx.json + project.json change applied together, repeatedly', () => {
for (let i = 0; i < 4; i++) {
const lib = uniq('lib');
runCLI(`generate @nx/js:lib libs/${lib}`);
createPlugin(
`combo-infer-${i}`,
`{
build: {
executor: 'nx:run-commands',
options: { command: 'echo build' },
inputs: ['inferred-${i}'],
}
}`
);
updateJson('nx.json', (json) => {
json.plugins = [`./tools/combo-infer-${i}`];
json.targetDefaults = { build: { inputs: [`defaults-${i}`] } };
return json;
});
updateJson(`libs/${lib}/project.json`, (c) => {
c.targets = { build: { inputs: [`project-${i}`, '...'] } };
return c;
});
const project = getResolvedProject(lib);
// Target defaults (no spread) replace the inferred inputs, then
// project.json spreads against the resolved defaults.
expect(project.targets.build.inputs).toEqual([
`project-${i}`,
`defaults-${i}`,
]);
}
});
});
});
+1 -1
View File
@@ -408,7 +408,7 @@
"nxCloudId": "62d013ea0852fe0a2df74438",
"nxCloudUrl": "https://staging.nx.app",
"parallel": 1,
"bust": 3235,
"bust": 3244,
"defaultBase": "master",
"sync": {
"applyChanges": true
@@ -107,35 +107,46 @@ let cacheHasBeenPersisted = false;
function kickOffRecompute() {
let myPromise: Promise<SerializedProjectGraph>;
myPromise = (async () => {
// Single read shared with getPluginsSeparated below. This collapses
// what would otherwise be two independent nx.json reads (our snap +
// the plugin loader's) into one, so the snap hash and the plugin
// set the compute uses always reflect the same disk state.
const nxJson = readNxJson(workspaceRoot);
const myPluginsHash = hashObject(nxJson.plugins ?? []);
// The whole body must resolve, never reject: scheduleProjectGraphRecomputation
// calls kickOffRecompute() fire-and-forget, so a rejected myPromise has no
// awaiter and crashes the daemon with an unhandled rejection. The prologue
// (readNxJson / getPluginsSeparated) can throw — e.g. a plugin fails to
// load — so a failure here is turned into an errorResult the next requester
// surfaces, same as processFilesAndCreateAndSerializeProjectGraph already does.
try {
// Single read shared with getPluginsSeparated below. This collapses
// what would otherwise be two independent nx.json reads (our snap +
// the plugin loader's) into one, so the snap hash and the plugin
// set the compute uses always reflect the same disk state.
const nxJson = readNxJson(workspaceRoot);
const myPluginsHash = hashObject(nxJson.plugins ?? []);
const plugins = await getPluginsSeparated(nxJson, workspaceRoot);
const plugins = await getPluginsSeparated(nxJson, workspaceRoot);
// Plugin set we just loaded may already be stale vs disk.
if (isStale(myPluginsHash)) return chainToSuccessor(myPromise);
// Plugin set we just loaded may already be stale vs disk.
if (isStale(myPluginsHash)) return chainToSuccessor(myPromise);
const result = await processFilesAndCreateAndSerializeProjectGraph(plugins);
const result =
await processFilesAndCreateAndSerializeProjectGraph(plugins);
// Compute may have run against plugins that are now stale.
if (isStale(myPluginsHash)) return chainToSuccessor(myPromise);
// Compute may have run against plugins that are now stale.
if (isStale(myPluginsHash)) return chainToSuccessor(myPromise);
if (
cachedSerializedProjectGraphPromise === myPromise &&
result.projectGraph
) {
notifyProjectGraphRecomputationListeners(
result.projectGraph,
result.sourceMaps,
result.error
);
persistProjectGraphToDisk(result);
if (
cachedSerializedProjectGraphPromise === myPromise &&
result.projectGraph
) {
notifyProjectGraphRecomputationListeners(
result.projectGraph,
result.sourceMaps,
result.error
);
persistProjectGraphToDisk(result);
}
return result;
} catch (e) {
return errorResult(e);
}
return result;
})();
cachedSerializedProjectGraphPromise = myPromise;
}
@@ -1,6 +1,111 @@
import { createSerializableError } from '../../utils/serializable-error';
import { reasonToError } from './get-plugins';
// Covers the daemon race where two recomputes call getPluginsSeparated
// back-to-back with the same NEW plugin config (after nx.json change).
// The earlier code path updated `currentPluginsConfigurationHash` to the
// new hash before populating `cachedSeparatedPlugins`, so a concurrent
// caller arriving in that window passed the cache check and received the
// STALE SeparatedPlugins from the previous load. In the daemon this
// surfaced as the project graph being built without the freshly-added
// specified plugins (see spread.test.ts middle-plugin flake).
describe('getPluginsSeparated — concurrent load of same new config', () => {
it('does not serve stale cached plugins to a concurrent caller mid-load', async () => {
await jest.isolateModulesAsync(async () => {
jest.doMock('./isolation/enabled', () => ({
__esModule: true,
isIsolationEnabled: () => false,
}));
let firstLoadGate: () => void;
const firstLoadParked = new Promise<void>((resolve) => {
firstLoadGate = resolve;
});
let specifiedLoadCount = 0;
const makeFakePlugin = (name: string) =>
({ name }) as unknown as import('./loaded-nx-plugin').LoadedNxPlugin;
// Only specified plugins (paths starting with "./") are gated;
// default plugin loads (absolute paths from getDefaultPlugins)
// run to completion so Promise.allSettled below progresses.
jest.doMock('./in-process-loader', () => ({
__esModule: true,
loadNxPlugin: (config: unknown) => {
const name =
typeof config === 'string'
? config
: ((config as { plugin: string }).plugin ?? 'unknown');
const isSpecifiedPlugin = name.startsWith('./');
let parkThis = false;
if (isSpecifiedPlugin) {
specifiedLoadCount++;
parkThis = specifiedLoadCount === 1;
}
const promise = (async () => {
if (parkThis) await firstLoadParked;
return makeFakePlugin(`loaded:${name}`);
})();
return [promise, () => {}] as const;
},
}));
const { getPluginsSeparated } = require('./get-plugins');
// Seed the cache with an EMPTY specified-plugin set so the race
// window has a meaningfully stale value to return. This matches
// the daemon's startup state right before the test mutates
// nx.json to add specified plugins.
const seed = await getPluginsSeparated({ plugins: [] }, '/tmp/fake-root');
expect(seed.specifiedPlugins).toEqual([]);
// First call after the nx.json change: falls through cache (hash
// differs from seed), bumps currentPluginsConfigurationHash to the
// NEW hash, and parks loading './tools/plugin-a'.
const callA = getPluginsSeparated(
{ plugins: ['./tools/plugin-a'] },
'/tmp/fake-root'
);
// Yield through the microtask + setImmediate queues so call A has
// (synchronously) stamped `currentPluginsConfigurationHash` to the
// NEW hash and is suspended on `Promise.allSettled` awaiting the
// parked load.
await new Promise((r) => setImmediate(r));
await new Promise((r) => setImmediate(r));
// Concurrent call with the SAME new config arriving in that race
// window. Without the fix it hits the cache check
// (`cachedSeparatedPlugins && newHash === currentHash`) and gets
// the seed's empty `specifiedPlugins` — the daemon would then
// build a project graph with no specified plugins loaded.
const callB = getPluginsSeparated(
{ plugins: ['./tools/plugin-a'] },
'/tmp/fake-root'
);
// Unblock the parked load and let both calls settle.
firstLoadGate!();
const [resultA, resultB] = await Promise.all([callA, callB]);
expect(resultA.specifiedPlugins).toHaveLength(1);
expect(resultA.specifiedPlugins[0]).toMatchObject({
name: 'loaded:./tools/plugin-a',
});
// The bug: B used to return `specifiedPlugins: []` because the
// cache check passed with the seed's stale data still attached
// to the freshly-updated hash.
expect(resultB.specifiedPlugins).toHaveLength(1);
expect(resultB.specifiedPlugins[0]).toMatchObject({
name: 'loaded:./tools/plugin-a',
});
expect(resultA.specifiedPlugins.map((p: any) => p.name)).toEqual(
resultB.specifiedPlugins.map((p: any) => p.name)
);
});
});
});
describe('reasonToError', () => {
it('should return the same Error instance when given a real Error', () => {
const error = new Error('real error');
@@ -26,6 +26,17 @@ let loadedPlugins: LoadedNxPlugin[];
let cachedSeparatedPlugins: SeparatedPlugins;
let pendingPluginsPromise: Promise<LoadedNxPlugin[]> | undefined;
let cleanupSpecifiedPlugins: () => void | undefined;
// In-flight `getPluginsSeparated` call paired with the plugin-config hash
// it's loading for. Lets a concurrent caller arriving with the same new
// config dedupe onto the existing load instead of either (a) hitting the
// cache check after `currentPluginsConfigurationHash` was bumped but
// before `cachedSeparatedPlugins` was replaced — that race served the
// previous load's stale result and surfaced as the spread.test.ts
// middle-plugin flake — or (b) racing a parallel reload that thrashes
// workers.
let pendingSeparatedPluginsLoad:
| { hash: string; promise: Promise<SeparatedPlugins> }
| undefined;
export interface SeparatedPlugins {
specifiedPlugins: LoadedNxPlugin[];
@@ -81,6 +92,21 @@ export async function getPluginsSeparated(
return cachedSeparatedPlugins;
}
// A load for this exact plugin set is already in flight (e.g. the
// watcher kicked off a recompute and a `nx show` request landed
// before it finished). Dedupe onto it instead of starting a parallel
// reload that thrashes workers — and, more importantly, instead of
// falling through to the cache check after a concurrent caller bumps
// `currentPluginsConfigurationHash` to the new hash but hasn't yet
// replaced `cachedSeparatedPlugins` (which was the spread.test.ts
// middle-plugin flake).
if (
pendingSeparatedPluginsLoad &&
pendingSeparatedPluginsLoad.hash === pluginsConfigurationHash
) {
return pendingSeparatedPluginsLoad.promise;
}
// Plugins config changed (e.g. `nx add @nx/maven` updated nx.json). The
// cached SeparatedPlugins is invalidated by the early-return above, but
// pendingPluginsPromise — the in-flight load — would otherwise be reused
@@ -88,36 +114,63 @@ export async function getPluginsSeparated(
// down the old workers and force a fresh load.
cleanupSpecifiedPlugins?.();
pendingPluginsPromise = undefined;
currentPluginsConfigurationHash = pluginsConfigurationHash;
const results = await Promise.allSettled([
getOnlyDefaultPlugins(root),
(pendingPluginsPromise ??= loadSpecifiedNxPlugins(
pluginsConfiguration,
root
)),
]);
const errors: Error[] = [];
const defaultPlugins: LoadedNxPlugin[] = [];
const specifiedPlugins: LoadedNxPlugin[] = [];
const myHash = pluginsConfigurationHash;
const myPromise = (async (): Promise<SeparatedPlugins> => {
try {
const results = await Promise.allSettled([
getOnlyDefaultPlugins(root),
(pendingPluginsPromise ??= loadSpecifiedNxPlugins(
pluginsConfiguration,
root
)),
]);
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === 'fulfilled') {
(i === 0 ? defaultPlugins : specifiedPlugins).push(...result.value);
} else {
errors.push(reasonToError(result.reason));
const errors: Error[] = [];
const defaultPlugins: LoadedNxPlugin[] = [];
const specifiedPlugins: LoadedNxPlugin[] = [];
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === 'fulfilled') {
(i === 0 ? defaultPlugins : specifiedPlugins).push(...result.value);
} else {
errors.push(reasonToError(result.reason));
}
}
if (errors.length > 0) {
throw new AggregateError(
errors,
errors.map((e) => e.message).join('\n')
);
}
const newCache: SeparatedPlugins = { specifiedPlugins, defaultPlugins };
// Only commit the cache + hash if we're still the most recent
// in-flight load. A newer config arrived during our load → that
// newer load will commit its own (correct) result; we must not
// overwrite it with our older one.
if (pendingSeparatedPluginsLoad?.promise === myPromise) {
cachedSeparatedPlugins = newCache;
currentPluginsConfigurationHash = myHash;
loadedPlugins = specifiedPlugins.concat(defaultPlugins);
}
return newCache;
} finally {
// Always drop the in-flight marker for our promise — on success
// the cache is committed above, on error we want the next caller
// to fall through and retry rather than be handed our rejection.
if (pendingSeparatedPluginsLoad?.promise === myPromise) {
pendingSeparatedPluginsLoad = undefined;
}
}
}
})();
if (errors.length > 0) {
throw new AggregateError(errors, errors.map((e) => e.message).join('\n'));
}
cachedSeparatedPlugins = { specifiedPlugins, defaultPlugins };
loadedPlugins = specifiedPlugins.concat(defaultPlugins);
return cachedSeparatedPlugins;
pendingSeparatedPluginsLoad = { hash: myHash, promise: myPromise };
return myPromise;
}
/**