@@ -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' ] ) ;
0 commit comments