@@ -175,6 +175,14 @@ export interface DestroyRunnerResult {
175175 retainedCount : number ;
176176 /** Number of resources that failed to delete. State is preserved on >0 errors. */
177177 errorCount : number ;
178+ /**
179+ * True when a graceful SIGINT (issue #816) stopped the destroy early. The
180+ * in-flight deletes finished, the (trimmed) state was preserved, and the
181+ * lock was released — but resources may remain, so the caller surfaces a
182+ * non-zero exit. Distinct from `errorCount > 0` (a resource actually failed
183+ * to delete): an interrupt is a user-requested stop, not a failure.
184+ */
185+ interrupted : boolean ;
178186}
179187
180188/**
@@ -303,6 +311,7 @@ export async function runDestroyForStack(
303311 deletedCount : 0 ,
304312 retainedCount : 0 ,
305313 errorCount : 0 ,
314+ interrupted : false ,
306315 } ;
307316
308317 const resourceCount = Object . keys ( state . resources ) . length ;
@@ -523,6 +532,66 @@ export async function runDestroyForStack(
523532 const renderer = getLiveRenderer ( ) ;
524533 renderer . start ( ) ;
525534
535+ // Graceful SIGINT handling (issue #816, Terraform parity). The first
536+ // Ctrl-C flips `draining` true: the reverse-DAG delete loop below stops
537+ // SCHEDULING new deletes (it checks the flag before each level and before
538+ // dispatching each resource), but the already-dispatched in-flight
539+ // `provider.delete` calls in the current level are awaited to completion.
540+ // Control then falls through to the `finally` block, which flushes the
541+ // incremental save-chain (issue #804) — leaving a clean, minimal preserved
542+ // state — and releases the stack lock. Without this the process would die
543+ // mid-destroy, skip the `finally`, and strand the lock for its 30m TTL.
544+ //
545+ // A SECOND Ctrl-C bypasses graceful shutdown entirely (`process.exit(130)`)
546+ // — the user has decided not to wait for the in-flight call.
547+ //
548+ // The handler reads/writes ONLY this call's closure state, and is removed in
549+ // the `finally` below, so no listener leaks across stacks. Nested-stack
550+ // destroys recurse into `runDestroyForStack`, registering one handler per
551+ // level — Node delivers SIGINT to every listener, so the first Ctrl-C drains
552+ // the parent AND every in-flight child, which is the intended behavior.
553+ let draining = false ;
554+ const sigintHandler = ( ) : void => {
555+ if ( draining ) {
556+ // Second Ctrl-C: force-quit without waiting for the in-flight delete.
557+ // The synchronous `process.exit(130)` bypasses the `finally` below,
558+ // so the stack lock is NOT released through the normal path (issue
559+ // #816). Fire a best-effort, un-awaited release first — it MAY land
560+ // before the process dies on a fast network — but always print the
561+ // exact recovery command so the user can recover deterministically if
562+ // it does not (a force-quit leaving a stranded lock would otherwise
563+ // re-introduce the 30m-TTL wait this issue fixes, just on this path).
564+ void ctx . lockManager . releaseLock ( stackName , regionForState ) . catch ( ( ) => {
565+ /* best-effort: the recovery line below is the real guarantee */
566+ } ) ;
567+ process . stderr . write (
568+ `\nForce-quit: stack lock may not be released. If the next run reports a lock, run: ` +
569+ `cdkd force-unlock ${ stackName } \n`
570+ ) ;
571+ process . exit ( 130 ) ;
572+ }
573+ draining = true ;
574+ // Route the notice through the live renderer so it doesn't collide with
575+ // the in-flight task display.
576+ renderer . printAbove ( ( ) => {
577+ process . stderr . write (
578+ '\nInterrupted — finishing in-flight deletes, then flushing state and releasing the lock ' +
579+ '(press Ctrl-C again to force-quit)...\n'
580+ ) ;
581+ } ) ;
582+ } ;
583+ // Each nested-stack level recurses into `runDestroyForStack` and registers
584+ // its own SIGINT listener, and each in-flight provider that installs its own
585+ // SIGINT handler (CustomResource / CloudFront / ACM / Route53) adds one more.
586+ // Deep nesting + high `--concurrency` can legitimately exceed Node's default
587+ // 10-listener cap and emit a scary MaxListenersExceededWarning that is NOT a
588+ // leak (every listener is removed in its own `finally`). Raise the ceiling
589+ // with generous headroom for real fan-out while still leaving the warning
590+ // active above it so an ACTUAL listener leak is not masked. `Math.max` keeps
591+ // this safe under recursion (never lowers an already-raised limit).
592+ process . setMaxListeners ( Math . max ( process . getMaxListeners ( ) , 100 ) ) ;
593+ process . on ( 'SIGINT' , sigintHandler ) ;
594+
526595 try {
527596 logger . info ( 'Building dependency graph...' ) ;
528597
@@ -584,6 +653,15 @@ export async function runDestroyForStack(
584653
585654 // Process levels in reverse order for deletion.
586655 for ( let levelIndex = executionLevels . length - 1 ; levelIndex >= 0 ; levelIndex -- ) {
656+ // Graceful SIGINT (issue #816): once draining, do not start a new
657+ // deletion level. Any level already in flight finished via its own
658+ // `Promise.all` below; remaining levels are left untouched and their
659+ // resources stay in the preserved state for a clean re-run.
660+ if ( draining ) {
661+ logger . debug ( 'Interrupted (draining) — not scheduling further deletion levels' ) ;
662+ break ;
663+ }
664+
587665 const level = executionLevels [ levelIndex ] ;
588666 if ( ! level ) continue ;
589667
@@ -594,6 +672,14 @@ export async function runDestroyForStack(
594672 const stackRegion = state . region ?? ctx . baseRegion ;
595673
596674 const deletePromises = level . map ( async ( logicalId ) => {
675+ // Graceful SIGINT (issue #816): if the interrupt landed after this
676+ // level's promises were created but before this resource's delete was
677+ // dispatched, skip it. It stays in the preserved state for re-run.
678+ // (Deletes already in flight when the interrupt arrives are NOT
679+ // cancelled — they run to completion; only not-yet-dispatched ones
680+ // bail here.)
681+ if ( draining ) return ;
682+
597683 const resource = state . resources [ logicalId ] ;
598684 if ( ! resource ) {
599685 logger . warn ( `Resource ${ logicalId } not found in state, skipping` ) ;
@@ -826,12 +912,21 @@ export async function runDestroyForStack(
826912 await Promise . all ( deletePromises ) ;
827913 }
828914
915+ // Carry the graceful-interrupt outcome (issue #816) into the result so the
916+ // CLI surfaces a non-zero exit. Read AFTER the level loop so a SIGINT that
917+ // arrived while the final level was draining is still observed.
918+ result . interrupted = draining ;
919+
829920 // Flush pending incremental persists BEFORE the final state decision so
830921 // a chained write can never land after deleteState and re-create the
831922 // state file. The chain never rejects (each link catches internally).
832923 await saveChain ;
833924
834- if ( result . errorCount === 0 ) {
925+ // Preserve state (rather than delete it) when there were delete errors OR
926+ // the destroy was gracefully interrupted (issue #816). An interrupt leaves
927+ // not-yet-deleted resources, so deleting the state file would orphan them.
928+ const preserveState = result . errorCount > 0 || result . interrupted ;
929+ if ( ! preserveState ) {
835930 await ctx . stateBackend . deleteState ( stackName , regionForState ) ;
836931 logger . debug ( 'State deleted' ) ;
837932 // Drop this stack's entries from the exports index so the next
@@ -856,26 +951,42 @@ export async function runDestroyForStack(
856951 `The state file may still list already-deleted resources; a re-run resolves them idempotently.`
857952 ) ;
858953 }
859- logger . warn ( `${ result . errorCount } resource(s) failed to delete. State preserved.` ) ;
954+ if ( result . interrupted ) {
955+ logger . warn (
956+ `Destroy interrupted — ${ Object . keys ( remainingResources ) . length } resource(s) not deleted. State preserved.`
957+ ) ;
958+ } else {
959+ logger . warn ( `${ result . errorCount } resource(s) failed to delete. State preserved.` ) ;
960+ }
860961 }
861962
862- // Summary glyph distinguishes clean destroy (✓) from partial failure
863- // (⚠). The CLI's exit code reflects the same split (0 vs 2) — see
864- // PartialFailureError in src/utils/error-handler.ts. Without the
963+ // Summary glyph distinguishes clean destroy (✓) from partial failure /
964+ // interrupt (⚠). The CLI's exit code reflects the same split (0 vs 2) —
965+ // see PartialFailureError in src/utils/error-handler.ts. Without the
865966 // visual marker, a partial failure scrolls past in the same shape
866967 // as a successful destroy and gets missed in CI / bench output.
867968 const retainedSuffix = result . retainedCount > 0 ? `, ${ result . retainedCount } retained` : '' ;
868- if ( result . errorCount === 0 ) {
969+ if ( ! preserveState ) {
869970 logger . info (
870971 `\n${ green ( '✓' ) } ${ bold ( `Stack ${ stackName } destroyed` ) } (${ green ( result . deletedCount ) } deleted${ retainedSuffix } , ${ result . errorCount } errors)`
871972 ) ;
973+ } else if ( result . interrupted && result . errorCount === 0 ) {
974+ logger . warn (
975+ `\n${ yellow ( '⚠' ) } ${ bold ( `Stack ${ stackName } destroy interrupted` ) } (${ green ( result . deletedCount ) } deleted${ retainedSuffix } , ${ result . errorCount } errors). ` +
976+ `State preserved — re-run 'cdkd destroy' / 'cdkd state destroy' to finish.`
977+ ) ;
872978 } else {
873979 logger . warn (
874980 `\n${ yellow ( '⚠' ) } ${ bold ( `Stack ${ stackName } partially destroyed` ) } (${ green ( result . deletedCount ) } deleted${ retainedSuffix } , ${ red ( result . errorCount ) } errors). ` +
875981 `State preserved — re-run 'cdkd destroy' / 'cdkd state destroy' to clean up.`
876982 ) ;
877983 }
878984 } finally {
985+ // Remove our SIGINT listener so it never leaks past this call (each
986+ // call registers and removes its own function reference — important for
987+ // nested-stack recursion, where one handler is registered per level).
988+ process . removeListener ( 'SIGINT' , sigintHandler ) ;
989+
879990 // Stop live renderer before releasing the lock so any pending in-flight
880991 // task lines are cleared cleanly.
881992 renderer . stop ( ) ;
0 commit comments