Skip to content

Commit 06d1c8b

Browse files
authored
feat(destroy): graceful SIGINT handling — release lock + preserve state on interrupt (#826)
1 parent 6ba1e81 commit 06d1c8b

8 files changed

Lines changed: 503 additions & 8 deletions

File tree

docs/_generated/integ-last-run.tsv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ kms-encryption 2026-06-10T10:20:55Z PASS standard coverage spread; 3 deleted 0
119119
sns-sqs-event 2026-06-10T10:20:55Z PASS verify.sh coverage spread; 19 deleted 0 errors
120120
vpc-lambda 2026-06-10T10:20:55Z PASS standard coverage spread; 16 deleted 0 errors, VPC/ENI clean
121121
drift-revert-vpc 2026-06-11T06:05:51Z PASS verify.sh order-normalize backport; 21 deleted 0 err 0 orphan
122-
microservices 2026-06-13T04:12:49Z PASS 39 standard #804 incremental destroy persistence; 19 deleted 0 err 0 orphan
122+
microservices 2026-06-13T06:25:57Z PASS 34 standard #816 SIGINT handler; normal destroy unaffected; 19 deleted 0 err 0 orphan
123123
lambda 2026-06-13T04:41:51Z PASS 76 verify.sh #808 broad integ + cdkd events live-tested (deploy+destroy runs persisted); 9 deleted 0 err
124124
vpc-nat-gateway 2026-06-13T05:15:45Z PASS 328 standard #817 IGW/NAT delete-order; 21 deleted 0 err 0 orphan (NAT before IGW/EIP)
125125
cross-region-state-bucket 2026-06-13T06:16:49Z PASS 34 verify.sh #819 exports-index region-corrected client; no 301; 1 deleted 0 err, temp bucket cleaned

docs/changelog-cdkd.md

Lines changed: 2 additions & 1 deletion
Large diffs are not rendered by default.

docs/state-management.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,28 @@ Default: **15 minutes**
592592

593593
Even if a process crashes, after 15 minutes the old lock is considered stale and can be force released.
594594

595+
### Destroy interruption (Ctrl-C)
596+
597+
`cdkd destroy` and `cdkd state destroy` handle the first `Ctrl-C` (SIGINT)
598+
gracefully (issue [#816](https://github.com/go-to-k/cdkd/issues/816)),
599+
mirroring Terraform:
600+
601+
- **First Ctrl-C** stops scheduling new deletes. Any provider delete already
602+
in flight is allowed to finish (it is not cancelled). The runner then flushes
603+
the incremental destroy state (the same per-resource save-chain that powers
604+
the partial-failure path — see "Incremental destroy persistence" below), so
605+
the preserved `state.json` lists only the resources that still exist.
606+
Finally it **releases the stack lock** and the command exits non-zero. A
607+
re-run of `cdkd destroy` resumes cleanly with no replay and no wait for the
608+
lock TTL.
609+
- **Second Ctrl-C** force-quits immediately (`process.exit(130)`) without
610+
waiting for the in-flight delete. In that case the lock may be left behind
611+
and is reclaimed after the TTL above (or cleared with `cdkd force-unlock`).
612+
613+
This is why an interrupted destroy no longer strands the lock for its full
614+
TTL: only an ungraceful kill (`SIGKILL`, a second Ctrl-C, or a crash) leaves a
615+
stale lock.
616+
595617
## State Saving and Updating
596618

597619
### Initial Save (New Stack)

docs/troubleshooting.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,28 @@ Locked by: user@hostname:12345, operation: deploy
3131
- Another process is deploying the same stack
3232
- Previous process crashed and lock remains
3333

34+
> **Note:** A first `Ctrl-C` during `cdkd destroy` / `cdkd state destroy` no
35+
> longer strands the lock — the graceful-SIGINT handler (issue
36+
> [#816](https://github.com/go-to-k/cdkd/issues/816)) finishes any in-flight
37+
> delete, flushes the incremental state, and **releases the lock** before
38+
> exiting non-zero. A re-run resumes immediately without waiting out the lock
39+
> TTL.
40+
>
41+
> A **second** `Ctrl-C` force-quits immediately (`exit 130`) without waiting
42+
> for the in-flight delete. Because the force-quit path cannot run the normal
43+
> lock-release cleanup, it fires a **best-effort** (un-awaited) lock release
44+
> AND prints the exact recovery command to stderr:
45+
>
46+
> ```text
47+
> Force-quit: stack lock may not be released. If the next run reports a lock, run: cdkd force-unlock MyStack
48+
> ```
49+
>
50+
> The best-effort release usually lands before the process dies, so most
51+
> force-quits leave no lock; if a subsequent run reports a lock, run the
52+
> printed `cdkd force-unlock <stackName>` (or the steps below) to clear it. A
53+
> leftover lock therefore means an ungraceful kill (`SIGKILL`, a force-quit
54+
> whose best-effort release did not complete, or a crash).
55+
3456
#### Solutions
3557
3658
**1. Check if another process is running**

src/cli/commands/destroy-runner.ts

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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();

src/cli/commands/destroy.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,9 @@ async function destroyCommand(
286286
// Hoisted out of the per-stack loop so the upfront nested-child-by-name
287287
// refusal (after the empty-match gate below) can use the same accumulator.
288288
let totalErrors = 0;
289+
// Set true when a per-stack destroy was gracefully interrupted (issue
290+
// #816). Stops the multi-stack loop and surfaces a non-zero exit below.
291+
let interrupted = false;
289292

290293
let stackNames: string[];
291294
if (options.all) {
@@ -560,6 +563,7 @@ async function destroyCommand(
560563
})
561564
);
562565
totalErrors += result.errorCount;
566+
if (result.interrupted) interrupted = true;
563567

564568
// Map the per-stack runner outcome to a run-level result. A
565569
// partial-failure (errorCount > 0) is a FAILED run; cancelled /
@@ -584,6 +588,11 @@ async function destroyCommand(
584588
} finally {
585589
await eventRecorder.finalize(destroyRunResult);
586590
}
591+
592+
// Graceful SIGINT (issue #816): do not start destroying further stacks
593+
// once the user has asked to stop. The interrupted stack already
594+
// finished its in-flight deletes, preserved state, and released its lock.
595+
if (interrupted) break;
587596
}
588597

589598
if (totalErrors > 0) {
@@ -597,6 +606,14 @@ async function destroyCommand(
597606
`inspect 'cdkd state show <stack>' and re-run 'cdkd destroy' to retry.`
598607
);
599608
}
609+
if (interrupted) {
610+
// Graceful SIGINT (issue #816): in-flight deletes finished, state was
611+
// preserved (trimmed), and the lock was released. Surface a non-zero
612+
// exit so scripts / CI see the destroy did not complete.
613+
throw new PartialFailureError(
614+
`Destroy interrupted by Ctrl-C. State preserved — re-run 'cdkd destroy' to finish.`
615+
);
616+
}
600617
} finally {
601618
// Cleanup AWS clients
602619
awsClients.destroy();

src/cli/commands/state.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,9 @@ async function stateDestroyCommand(
11841184
logger.info(`Found ${stackNames.length} stack(s) to destroy: ${stackNames.join(', ')}`);
11851185

11861186
let totalErrors = 0;
1187+
// Set true when a per-stack destroy was gracefully interrupted (issue
1188+
// #816). Stops the multi-stack loop and surfaces a non-zero exit below.
1189+
let interrupted = false;
11871190
for (const stackName of stackNames) {
11881191
// After PR 1, the same stackName can have state in multiple regions.
11891192
// Pick the right ref(s):
@@ -1293,7 +1296,16 @@ async function stateDestroyCommand(
12931296
})
12941297
);
12951298
totalErrors += result.errorCount;
1299+
if (result.interrupted) interrupted = true;
1300+
// Graceful interrupt (issue #816): stop iterating this stack's regions.
1301+
if (interrupted) break;
12961302
}
1303+
1304+
// Graceful interrupt (issue #816): stop the outer multi-stack loop too —
1305+
// do not start destroying further stacks once the user has asked to
1306+
// stop. Explicit guard mirroring destroy.ts's stack-loop break (the
1307+
// inner `break` above only exits the per-region loop).
1308+
if (interrupted) break;
12971309
}
12981310

12991311
if (totalErrors > 0) {
@@ -1305,6 +1317,14 @@ async function stateDestroyCommand(
13051317
`inspect 'cdkd state show <stack>' and re-run 'cdkd state destroy' to retry.`
13061318
);
13071319
}
1320+
if (interrupted) {
1321+
// Graceful SIGINT (issue #816): in-flight deletes finished, state was
1322+
// preserved (trimmed), and the lock was released. Surface a non-zero
1323+
// exit so scripts / CI see the destroy did not complete.
1324+
throw new PartialFailureError(
1325+
`Destroy interrupted by Ctrl-C. State preserved — re-run 'cdkd state destroy' to finish.`
1326+
);
1327+
}
13081328
} finally {
13091329
setup.dispose();
13101330
}

0 commit comments

Comments
 (0)