Skip to content

Commit 6317340

Browse files
authored
fix(core): ensure workers shutdown after phase cancelled (#34799)
## Current Behavior New logic in the daemon can cancel an active graph creation, resulting in worker shutdown not behaving as intended ## Expected Behavior When the daemon aborts graph construction, the plugins still shut down ## Related Issue(s) <!-- Please link the issue being fixed so it gets closed when this is merged. --> Fixes #
1 parent 6482a2c commit 6317340

5 files changed

Lines changed: 266 additions & 0 deletions

File tree

packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ async function processFilesAndCreateAndSerializeProjectGraph(
357357

358358
// Early exit if a newer recomputation has started - chain to the newer one
359359
if (isStale()) {
360+
notifyPluginsGraphAborted(plugins);
360361
return cachedSerializedProjectGraphPromise;
361362
}
362363

@@ -381,6 +382,7 @@ async function processFilesAndCreateAndSerializeProjectGraph(
381382

382383
// Early exit if a newer recomputation has started - chain to the newer one
383384
if (isStale()) {
385+
notifyPluginsGraphAborted(plugins);
384386
return cachedSerializedProjectGraphPromise;
385387
}
386388

@@ -549,6 +551,15 @@ async function resetInternalStateIfNxDepsMissing() {
549551
}
550552
}
551553

554+
function notifyPluginsGraphAborted(plugins: LoadedNxPlugin[]) {
555+
// At both abort sites, only createNodes has been called.
556+
// createDependencies and createMetadata are called later in
557+
// createAndSerializeProjectGraph, which hasn't run yet.
558+
for (const plugin of plugins) {
559+
plugin.abortGraphPhase?.('createNodes');
560+
}
561+
}
562+
552563
function notifyProjectGraphRecomputationListeners(
553564
projectGraph: ProjectGraph,
554565
sourceMaps: ConfigurationSourceMaps,

packages/nx/src/project-graph/plugins/isolation/isolated-plugin.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,12 @@ export class IsolatedPlugin implements LoadedNxPlugin {
434434
this.shutdown();
435435
}
436436

437+
abortGraphPhase(lastCompletedHook: Hook): void {
438+
if (this.lifecycle?.abortPhase('graph', lastCompletedHook)) {
439+
this.shutdownIfInactive();
440+
}
441+
}
442+
437443
shutdown(): void {
438444
if (!this._alive) return;
439445
this._alive = false;

packages/nx/src/project-graph/plugins/isolation/plugin-lifecycle-manager.spec.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,199 @@ describe('PluginLifecycleManager', () => {
349349
});
350350
});
351351

352+
describe('abortPhase', () => {
353+
it('should decrement session count when last completed hook is not the last registered hook', () => {
354+
const lifecycle = new PluginLifecycleManager([
355+
'createNodes',
356+
'createDependencies',
357+
'createMetadata',
358+
]);
359+
360+
// Simulate entering graph phase (createNodes is first hook)
361+
lifecycle.enterHook('createNodes');
362+
lifecycle.exitHook('createNodes'); // not last hook, no decrement
363+
expect(lifecycle.getPhaseRefCount('graph')).toBe(1);
364+
365+
// Abort after createNodes — createDependencies and createMetadata remain
366+
const shouldShutdown = lifecycle.abortPhase('graph', 'createNodes');
367+
expect(lifecycle.getPhaseRefCount('graph')).toBe(0);
368+
expect(shouldShutdown).toBe(true); // graph is the only registered phase
369+
});
370+
371+
it('should be a no-op when last completed hook IS the last registered hook', () => {
372+
const lifecycle = new PluginLifecycleManager(['createNodes']);
373+
374+
// Single hook: createNodes is both first and last.
375+
// exitHook already closed the session.
376+
lifecycle.enterHook('createNodes');
377+
lifecycle.exitHook('createNodes');
378+
expect(lifecycle.getPhaseRefCount('graph')).toBe(0);
379+
380+
// abortPhase with createNodes as last completed — it's the last registered hook
381+
const shouldShutdown = lifecycle.abortPhase('graph', 'createNodes');
382+
expect(shouldShutdown).toBe(false);
383+
expect(lifecycle.getPhaseRefCount('graph')).toBe(0);
384+
});
385+
386+
it('should be a no-op when aborting after completing all hooks in a multi-hook phase', () => {
387+
const lifecycle = new PluginLifecycleManager([
388+
'createNodes',
389+
'createDependencies',
390+
]);
391+
392+
// Complete the full phase
393+
lifecycle.enterHook('createNodes');
394+
lifecycle.exitHook('createNodes');
395+
lifecycle.enterHook('createDependencies');
396+
lifecycle.exitHook('createDependencies'); // last hook, decrements
397+
expect(lifecycle.getPhaseRefCount('graph')).toBe(0);
398+
399+
// Abort after createDependencies — it's the last registered hook, session closed
400+
const shouldShutdown = lifecycle.abortPhase(
401+
'graph',
402+
'createDependencies'
403+
);
404+
expect(shouldShutdown).toBe(false);
405+
expect(lifecycle.getPhaseRefCount('graph')).toBe(0);
406+
});
407+
408+
it('should not steal another callers session in single-hook phase', () => {
409+
const lifecycle = new PluginLifecycleManager(['createNodes']);
410+
411+
// Caller A completes createNodes (session fully closed)
412+
lifecycle.enterHook('createNodes');
413+
lifecycle.exitHook('createNodes');
414+
415+
// Caller B enters createNodes (new session)
416+
lifecycle.enterHook('createNodes');
417+
expect(lifecycle.getPhaseRefCount('graph')).toBe(1);
418+
419+
// Caller A aborts — createNodes is the last registered hook, no-op
420+
const shouldShutdown = lifecycle.abortPhase('graph', 'createNodes');
421+
expect(shouldShutdown).toBe(false);
422+
expect(lifecycle.getPhaseRefCount('graph')).toBe(1);
423+
424+
// Caller B completes normally
425+
expect(lifecycle.exitHook('createNodes')).toBe(true);
426+
expect(lifecycle.getPhaseRefCount('graph')).toBe(0);
427+
});
428+
429+
it('should be a no-op when hook is not registered for the plugin', () => {
430+
// Plugin only has createDependencies, not createNodes
431+
const lifecycle = new PluginLifecycleManager(['createDependencies']);
432+
433+
// Abort with createNodes — not registered, so no session was opened
434+
const shouldShutdown = lifecycle.abortPhase('graph', 'createNodes');
435+
expect(shouldShutdown).toBe(false);
436+
});
437+
438+
it('should return false when phase has no active sessions', () => {
439+
const lifecycle = new PluginLifecycleManager([
440+
'createNodes',
441+
'createDependencies',
442+
]);
443+
444+
const shouldShutdown = lifecycle.abortPhase('graph', 'createNodes');
445+
expect(shouldShutdown).toBe(false);
446+
expect(lifecycle.getPhaseRefCount('graph')).toBe(0);
447+
});
448+
449+
it('should return false for unregistered phase', () => {
450+
const lifecycle = new PluginLifecycleManager(['createNodes']);
451+
452+
const shouldShutdown = lifecycle.abortPhase(
453+
'pre-task',
454+
'preTasksExecution'
455+
);
456+
expect(shouldShutdown).toBe(false);
457+
});
458+
459+
it('should return false when later phases have hooks', () => {
460+
const lifecycle = new PluginLifecycleManager([
461+
'createNodes',
462+
'createDependencies',
463+
'postTasksExecution',
464+
]);
465+
466+
lifecycle.enterHook('createNodes');
467+
lifecycle.exitHook('createNodes');
468+
const shouldShutdown = lifecycle.abortPhase('graph', 'createNodes');
469+
expect(lifecycle.getPhaseRefCount('graph')).toBe(0);
470+
expect(shouldShutdown).toBe(false); // post-task phase still registered
471+
});
472+
473+
it('should decrement by 1 with concurrent callers', () => {
474+
const lifecycle = new PluginLifecycleManager([
475+
'createNodes',
476+
'createDependencies',
477+
'createMetadata',
478+
]);
479+
480+
// Two concurrent callers enter and exit createNodes (first hook, not last)
481+
lifecycle.enterHook('createNodes');
482+
lifecycle.exitHook('createNodes');
483+
lifecycle.enterHook('createNodes');
484+
lifecycle.exitHook('createNodes');
485+
expect(lifecycle.getPhaseRefCount('graph')).toBe(2);
486+
487+
// First caller aborts after createNodes
488+
expect(lifecycle.abortPhase('graph', 'createNodes')).toBe(false);
489+
expect(lifecycle.getPhaseRefCount('graph')).toBe(1);
490+
491+
// Second caller aborts after createNodes
492+
expect(lifecycle.abortPhase('graph', 'createNodes')).toBe(true);
493+
expect(lifecycle.getPhaseRefCount('graph')).toBe(0);
494+
});
495+
496+
it('should handle abort after second of three hooks', () => {
497+
const lifecycle = new PluginLifecycleManager([
498+
'createNodes',
499+
'createDependencies',
500+
'createMetadata',
501+
]);
502+
503+
// Caller enters createNodes and createDependencies but not createMetadata
504+
lifecycle.enterHook('createNodes');
505+
lifecycle.exitHook('createNodes');
506+
lifecycle.enterHook('createDependencies');
507+
lifecycle.exitHook('createDependencies');
508+
expect(lifecycle.getPhaseRefCount('graph')).toBe(1); // session still open
509+
510+
// Abort after createDependencies — createMetadata remains
511+
const shouldShutdown = lifecycle.abortPhase(
512+
'graph',
513+
'createDependencies'
514+
);
515+
expect(lifecycle.getPhaseRefCount('graph')).toBe(0);
516+
expect(shouldShutdown).toBe(true);
517+
});
518+
519+
it('should allow normal hook flow after abort', () => {
520+
const lifecycle = new PluginLifecycleManager([
521+
'createNodes',
522+
'createDependencies',
523+
'createMetadata',
524+
]);
525+
526+
// First recomputation enters but gets aborted after createNodes
527+
lifecycle.enterHook('createNodes');
528+
lifecycle.exitHook('createNodes');
529+
lifecycle.abortPhase('graph', 'createNodes');
530+
expect(lifecycle.getPhaseRefCount('graph')).toBe(0);
531+
532+
// Second recomputation runs to completion
533+
lifecycle.enterHook('createNodes');
534+
expect(lifecycle.getPhaseRefCount('graph')).toBe(1);
535+
lifecycle.exitHook('createNodes');
536+
lifecycle.enterHook('createDependencies');
537+
lifecycle.exitHook('createDependencies');
538+
lifecycle.enterHook('createMetadata');
539+
const shouldShutdown = lifecycle.exitHook('createMetadata');
540+
expect(shouldShutdown).toBe(true);
541+
expect(lifecycle.getPhaseRefCount('graph')).toBe(0);
542+
});
543+
});
544+
352545
describe('wrapHook', () => {
353546
it('should call enterHook before and exitHook after the wrapped function', async () => {
354547
const lifecycle = new PluginLifecycleManager(['createNodes']);

packages/nx/src/project-graph/plugins/isolation/plugin-lifecycle-manager.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,52 @@ export class PluginLifecycleManager {
174174
return phaseIndex === this.registeredPhaseOrder.length - 1;
175175
}
176176

177+
/**
178+
* Aborts a single session within a phase by decrementing its count,
179+
* but only if the caller's session is still open.
180+
*
181+
* Called when graph construction is abandoned mid-flight (e.g., a newer
182+
* recomputation supersedes the current one). Without this, hooks that
183+
* were entered but whose phase never completed would leave the session
184+
* count elevated, preventing the worker from ever shutting down.
185+
*
186+
* @param phase The phase to abort
187+
* @param lastCompletedHook The last hook the caller executed before aborting.
188+
* Used to determine whether the caller's session is still open: if there
189+
* are registered hooks after this one, the session is open and needs
190+
* cleanup. If this IS the last registered hook, exitHook already closed
191+
* the session — decrementing again would steal another caller's count.
192+
* @returns true if the worker should shut down, false otherwise
193+
*/
194+
abortPhase(phase: Phase, lastCompletedHook: Hook): boolean {
195+
const phaseHooks = this.registeredPhases[phase];
196+
if (!phaseHooks) {
197+
return false;
198+
}
199+
200+
const hookIndex = phaseHooks.indexOf(lastCompletedHook);
201+
202+
// Hook not registered → this caller never entered the phase → no session to abort.
203+
// Hook is the last registered hook → exitHook already closed the session.
204+
if (hookIndex === -1 || hookIndex === phaseHooks.length - 1) {
205+
return false;
206+
}
207+
208+
const count = this.phaseSessionCount[phase] ?? 0;
209+
if (count === 0) {
210+
return false;
211+
}
212+
213+
const newCount = count - 1;
214+
this.phaseSessionCount[phase] = newCount;
215+
216+
if (newCount > 0) {
217+
return false;
218+
}
219+
220+
return this.isLastRegisteredPhase(phase);
221+
}
222+
177223
/**
178224
* Returns the current session count for a phase. Useful for testing.
179225
*/

packages/nx/src/project-graph/plugins/loaded-nx-plugin.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ export class LoadedNxPlugin {
5555
readonly include?: string[];
5656
readonly exclude?: string[];
5757

58+
/**
59+
* Notifies the plugin that graph construction was aborted mid-flight.
60+
* Overridden by IsolatedPlugin to reset lifecycle phase tracking so
61+
* the worker can still shut down properly.
62+
*
63+
* @param lastCompletedHook The name of the last graph-phase hook that
64+
* was called before the abort (e.g. 'createNodes').
65+
*/
66+
abortGraphPhase?(lastCompletedHook: string): void;
67+
5868
constructor(
5969
plugin: NxPluginV2,
6070
pluginDefinition: PluginConfiguration,

0 commit comments

Comments
 (0)