fix(cli): fail cleanly when the --outputs-file write fails - #376
Conversation
There was a problem hiding this comment.
Verdict: APPROVE — clean fix, well-scoped, good tests, all checks green.
What this fixes
A failed --outputs-file write after a successful deploy crashes the CLI with a raw stack trace — but only after printing the false "The outputs have been written to..." line. The root cause: DeployConfig.onOutputsRetrieved in packages/cdktn-cli/src/bin/cmds/ui/deploy.ts is typed (outputs) => void, so TypeScript silently accepts an async function, and the returned promise is never awaited. A write failure becomes a floating unhandled rejection that runDeploy never catches.
What changed
handlers.ts— type annotation:void→void | Promise<void>foronOutputsRetrieved(bothdeployandoutputpaths)deploy.ts— awaitsonOutputsRetrieved, wraps failures asErrors.External, moves the "written to" line inside the success path onlyoutput.ts— identical treatment forrunOutput, plus a callback simplification in therunCdktfProjectcall (the old code fetched outputs, calledonOutputsRetrieved, then returned — the new code returns the fetch promise directly and awaitsonOutputsRetrievedafterward; same ordering, better error typing)deploy.test.ts— two behavioral tests: (1) verifiesrunDeployrejects with__type === "External"on async write failure rather than resolving silently, (2) verifies the false success message is not printed
Correctness
✅ The callback refactoring in output.ts is behaviorally equivalent.
✅ Error wrapping is identical between deploy.ts and output.ts.
✅ Errors.External maps to the cdktn.ts .fail() handler that prints a single clean line instead of a stack trace.
✅ Non-Error rejection values handled (e instanceof Error guard).
✅ No behavioral regression: a failing --outputs-file write was always fatal (exit code 1 in v0.24.0, verified against published CLIs).
Checks
| Command | Result |
|---|---|
pnpm exec jest packages/cdktn/test/validations.test.ts --runInBand |
✅ 41 passed |
pnpm exec nx build cdktn |
✅ success |
pnpm exec nx test cdktn --runInBand |
✅ 43 suites, 542 tests, all passed |
One minor note
output.ts (runOutput) has no direct test coverage for its error-wrapping path. The code is structurally identical to the well-tested deploy.ts path, so the risk is low — but a follow-up test for symmetry would close the gap.
Artifact-value
No dead code, no premature abstraction, no low-signal tests. Every artifact earns its maintenance cost. Ship.
|
Root cause confirmed, and The description and the code disagree on one point.
The cdk-terrain/packages/cdktn-cli/src/bin/cmds/ui/deploy.ts Lines 167 to 183 in d301821 (same ordering in Which did you mean?
Failing fast is defensible; I'd just like the two to agree. Two smaller things:
|
|
You're right that they disagree, and the answer is the second option — I want the table to print, so the Not a leftover sentence: I'd written the description from the intent and then not checked it against the final The reasoning for render-then-throw: at that point the deploy has genuinely succeeded and the outputs exist in New ordering: render → save → confirm. The
Double path — fixing. The fs error message already names the path, so the prefix drops it: All three incoming; I'll push and re-request. The PR description gets corrected along with the code. |
A failed `--outputs-file` write after a successful deploy crashed the CLI with a raw stack trace, having first told the user the file had been written. `DeployConfig.onOutputsRetrieved` is typed `(outputs) => void`, and a `void` return type silently accepts an `async` function, so TypeScript never flagged that `handlers.ts` wires it to the `async` `saveOutputs` while `runDeploy` calls it without `await`. A write failure therefore became a floating rejected promise that no `catch` could observe: `runDeploy` resolved normally, execution carried on to print "The outputs have been written to <path>", and the process then died on the unhandled rejection. Awaiting the callback and guarding it separately from the rendering, because the two failures deserve different treatment: * A failed outputs-file write stays fatal - the user explicitly asked for that file, and exiting 0 would let a pipeline consume a stale or absent one. It is now wrapped as an `External` error, which `cdktn.ts`'s `.fail()` prints as a single clean line instead of a stack trace and a crash report. * A failure only *rendering* the outputs table remains non-fatal, since the deploy and the write have both already succeeded by then. Splitting the guards also means a failing write no longer skips the outputs display, and the "written to" line is now only reachable once the write has actually succeeded. This is not a success -> failure change. Verified against published CLIs with a deploy whose outputs-file write fails: cdktf-cli 0.21.0 exits 7, cdktn-cli 0.23.4 exits 7, cdktn-cli 0.24.0 exits 1. (The two older ones ship Ink; the 7 is Node's "Internal Exception Handler Run-Time Failure" from the Sentry uncaughtException handler faulting during Ink teardown.) Relative to 0.24.0 the exit code is unchanged - only the message improves. All three printed the false "written to" line before crashing. Reproduced on both macOS and Linux. `cdktn output --outputs-file` had the identical fire-and-forget defect and gets the same treatment. `watch` never uses this callback.
runOutput wrote --outputs-file before rendering the outputs table, so a failing write (mistyped path) skipped the table the same way runDeploy used to before its own reordering during the rebase resolution. Apply the same fix here: render first (still non-fatal), then await the save and throw on failure, printing "written to <path>" only once the save actually succeeds. Classify the save failure by errno instead of by message text. ENOENT/ENOTDIR means the user pointed --outputs-file at a path that doesn't exist - a usage mistake, not something outside our control - so it is now reported as Errors.Usage (excluded from Sentry crash reporting) in both runDeploy and runOutput; other fs errors (EACCES, ENOSPC, ...) stay External. Also drop the path from the "Failed to write outputs to X: ENOENT: ... open 'X'" message - the fs error already names the path, so the prefix no longer repeats it. Adds direct coverage for runOutput's error-wrapping path (previously untested): Usage vs External classification, the outputs table printing before a save failure, and the "written to" line staying unreachable on failure.
The mock resolved a returnValue without ever invoking the project callback, so `project.fetchOutputs` never ran and the suite would still have passed if runOutput stopped fetching outputs entirely.
d301821 to
c35fcba
Compare
|
Pushed — rebased onto the updated #375, then
Two notes against myself: The first version of those And a behaviour interaction worth knowing when reading the two PRs together: with |
| } | ||
| } else { | ||
|
|
||
| // Render the outputs table first (still non-fatal): the fetch already succeeded, so a |
There was a problem hiding this comment.
This entire block is pretty much duplicated in deploy.ts. As the complexity has grown, should really make a reusable block.
Related issue
No GitHub issue — found while reviewing #375 (the outputs-rendering crash reported via Sentry as CDKTN-5). It is
a separate crash on a different code path, which is why it is split out rather than folded in: the
CDKTN-5 reporter never passed
--outputs-file, so none of this is on that crash's path.Description
A failed
--outputs-filewrite after an otherwise successful deploy crashed the CLI with a raw stack trace —having first told the user the file had been written.
Root cause
DeployConfig.onOutputsRetrievedis typed(outputs) => void. Avoidreturn type silently accepts anasyncfunction, so TypeScript never flagged thathandlers.tswires it to theasyncsaveOutputswhilerunDeploycalled it withoutawait:A write failure therefore became a floating rejected promise that no
catchcould observe.runDeployresolvednormally, execution carried on to print
The outputs have been written to <path>, and the process then died onthe unhandled rejection.
This was verified by probe rather than by reading: with an async-throwing callback the
catchnever fires andrunDeployresolves; with a sync-throwing one it fires normally.This is not a success → failure change
The obvious reviewer question is whether making this fatal changes behaviour. It does not — a failing
--outputs-filewrite has always been fatal, just via an ugly crash. Measured against published CLIs with adeploy whose outputs-file write fails:
cdktf-cli@0.21.0(last cdktf release)cdktn-cli@0.23.4cdktn-cli@0.24.0No version exits
0. The7on the two older ones is Node's "Internal Exception Handler Run-Time Failure" —the Sentry
uncaughtExceptionhandler faulting during Ink teardown; both shipink/yoga-layout-prebuilt,0.24.0 ships neither. Relative to 0.24.0 the exit code is unchanged; only the message improves. All three
printed the false success line before crashing. Reproduced on macOS and on Linux (Ubuntu, Node 22).
Since the strict/lenient decision was effectively already made, no new flag or opt-in is introduced. That would
also cut against the repo's convention, where leniency is opt-in (
skip-synth,skip-provider-lock,ignore-missing-stack-dependencies,force) rather than strictness.The fix
The callback is awaited, and guarded separately from the rendering, because the two failures deserve
different treatment:
0would let apipeline consume a stale or absent one. It is wrapped as an
Externalerror, whichcdktn.ts's.fail()prints as a single clean line instead of a stack trace and a crash report.
already succeeded by then.
Splitting the guards also means a failing write no longer skips the outputs display, and the
written toline isonly reachable once the write has actually succeeded.
cdktn output --outputs-filehad the identical fire-and-forget defect and gets the same treatment.watchneveruses this callback.
Note for reviewers
Errors.Externalfires an unawaited telemetry POST tocheckpoint-api.hashicorp.com— everyErrors.*factorydoes. So this change adds two call sites that report a purely local file-write failure to HashiCorp. The
alternative (not using the helper) means a stack trace plus a Sentry report, which is worse, so
Externalis theright trade here. Flagging it because it is a live data point for #62, which replaces that transport.
Checklist