Skip to content

fix(cli): fail cleanly when the --outputs-file write fails - #376

Open
so0k wants to merge 3 commits into
fix/deploy-outputs-undefined-crashfrom
fix/outputs-file-write-failures
Open

fix(cli): fail cleanly when the --outputs-file write fails#376
so0k wants to merge 3 commits into
fix/deploy-outputs-undefined-crashfrom
fix/outputs-file-write-failures

Conversation

@so0k

@so0k so0k commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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.

Stacked on #375. This PR targets fix/deploy-outputs-undefined-crash, so the diff shown here is only this
change. Review #375 first.

Description

A failed --outputs-file write 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.onOutputsRetrieved is typed (outputs) => void. A void return type silently accepts an
async function
, so TypeScript never flagged that handlers.ts wires it to the async saveOutputs while
runDeploy called it without await:

onOutputsRetrieved(outputs);   // returns a promise nobody holds

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.

This was verified by probe rather than by reading: with an async-throwing callback the catch never fires and
runDeploy resolves; 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-file write has always been fatal, just via an ugly crash. Measured against published CLIs with a
deploy whose outputs-file write fails:

CLI Exit code Stack trace False "written to" line
cdktf-cli@0.21.0 (last cdktf release) 7 yes yes
cdktn-cli@0.23.4 7 yes yes
cdktn-cli@0.24.0 1 yes yes

No version exits 0. The 7 on the two older ones is Node's "Internal Exception Handler Run-Time Failure"
the Sentry uncaughtException handler faulting during Ink teardown; both ship ink/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:

  • A failed outputs-file write stays fatal. The user explicitly asked for that file; exiting 0 would let a
    pipeline consume a stale or absent one. It is 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
only reachable once the write has actually succeeded.

cdktn output --outputs-file had the identical fire-and-forget defect and gets the same treatment. watch never
uses this callback.

Note for reviewers

Errors.External fires an unawaited telemetry POST to checkpoint-api.hashicorp.com — every Errors.* factory
does. 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 External is the
right trade here. Flagging it because it is a live data point for #62, which replaces that transport.

Checklist

  • I have updated the PR title to match CDKTN's style guide
  • I have run the linter on my code locally
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation if applicable — n/a, the exit-code contract is unchanged
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works if applicable
  • New and existing unit tests pass locally with my changes

@so0k
so0k marked this pull request as ready for review August 7, 2026 12:23
@so0k
so0k requested a review from a team as a code owner August 7, 2026 12:23

@sakul-learning sakul-learning left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: voidvoid | Promise<void> for onOutputsRetrieved (both deploy and output paths)
  • deploy.ts — awaits onOutputsRetrieved, wraps failures as Errors.External, moves the "written to" line inside the success path only
  • output.ts — identical treatment for runOutput, plus a callback simplification in the runCdktfProject call (the old code fetched outputs, called onOutputsRetrieved, then returned — the new code returns the fetch promise directly and awaits onOutputsRetrieved afterward; same ordering, better error typing)
  • deploy.test.ts — two behavioral tests: (1) verifies runDeploy rejects 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.


@jsteinich

Copy link
Copy Markdown
Contributor

Root cause confirmed, and deploy/output are indeed the only two commands using the callback.

The description and the code disagree on one point.

Splitting the guards also means a failing write no longer skips the outputs display

The throw Errors.External(...) sits before the render block, so a failing write does skip the display:

// A failed --outputs-file write is a broken promise to the user and must be fatal: await it and
// rethrow as an External error, which cdktn.ts's top-level `.fail()` handler prints as a single
// clean line rather than a stack trace, since the deploy itself already succeeded.
try {
await onOutputsRetrieved(outputs);
} catch (e) {
throw Errors.External(
`Failed to write outputs${outputsPath ? ` to ${outputsPath}` : ""}: ${
e instanceof Error ? e.message : e
}`,
e instanceof Error ? e : undefined,
);
}
if (outputs && Object.keys(outputs).length > 0) {
try {
console.log(renderOutputs(outputs));

(same ordering in runOutput at L88-L101). Against 0.24.0's actual async behaviour, the table is now skipped where it previously printed before the crash.

Which did you mean?

  • Leftover from an earlier draft → drop the sentence.
  • You want the table to still print → the throw moves below the render block (collect, render, throw), plus a test.

Failing fast is defensible; I'd just like the two to agree.

Two smaller things:

  1. ENOENT on a user-named directory reads more like Errors.Usage than External. Same clean-print branch either way, so semantics only — but EACCES/ENOSPC do fit External. Your call.
  2. The path renders twice: Failed to write outputs to X: ENOENT: no such file or directory, open 'X'.

@so0k

so0k commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

You're right that they disagree, and the answer is the second option — I want the table to print, so the
throw moves below the render block.

Not a leftover sentence: I'd written the description from the intent and then not checked it against the final
ordering, which is exactly the kind of thing a description should not get away with. Thanks for catching it.

The reasoning for render-then-throw: at that point the deploy has genuinely succeeded and the outputs exist in
memory. The only thing that failed is persisting them. Skipping the table means the user loses the values
entirely — they can't even copy them out of the terminal — for a failure that is often a mistyped --outputs-file
path they can just retry. Printing the table and then failing gives them both the data and the error. Your point
that this is a regression against 0.24.0's actual behaviour (where the table printed before the crash) makes it
worse than a wash, so failing fast here is losing information users had before.

New ordering: render → save → confirm. The written to line stays after the save, so it is still unreachable on
failure. With a test asserting the table is printed and the run rejects.

Errors.Usage for ENOENT — agreed, and it turns out to matter beyond semantics.

Usage errors are excluded from Sentry crash reporting, so classifying a user's mistyped path as External
would file a crash report for something that isn't a bug. Going with: ENOENT/ENOTDIRUsage, everything
else (EACCES, ENOSPC, …) → External, keyed off err.code rather than message text.

Double path — fixing. The fs error message already names the path, so the prefix drops it:
Failed to write outputs: ENOENT: no such file or directory, open 'X'.

All three incoming; I'll push and re-request. The PR description gets corrected along with the code.

so0k added 3 commits August 7, 2026 21:45
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.
@so0k
so0k force-pushed the fix/outputs-file-write-failures branch from d301821 to c35fcba Compare August 7, 2026 15:10
@so0k

so0k commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Pushed — rebased onto the updated #375, then 2b03546f6 + c35fcba41. All four points addressed.

  • Render before failing. New ordering is render → save → confirm, in both runDeploy and runOutput. The
    written to line stays after the save so it's still unreachable on failure. Test asserts the table is
    printed and the run still rejects. The description sentence is now true rather than deleted.
  • ENOENT/ENOTDIRErrors.Usage, everything else stays External, keyed off err.code rather than
    message text. Tests cover both (ENOENT → Usage, EACCES → External).
  • Path printed once — the prefix no longer repeats what the fs error already names.
  • runOutput now has direct coverage (ui/__tests__/output.test.ts), modelled on its real shape —
    returnValue from the project callback, not outputsByConstructId.

Two notes against myself:

The first version of those runOutput tests passed for a partly wrong reason: the mock resolved a returnValue
without ever invoking the project callback, so fetchOutputs never ran and the suite would still have passed if
runOutput stopped fetching outputs altogether. Fixed in c35fcba41.

And a behaviour interaction worth knowing when reading the two PRs together: with --outputs-file and all
outputs dropped, #375 alone prints only No outputs found., whereas after this PR it prints that and the
written to line, since that line is now keyed off write success rather than render state.

}
} else {

// Render the outputs table first (still non-fatal): the fetch already succeeded, so a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entire block is pretty much duplicated in deploy.ts. As the complexity has grown, should really make a reusable block.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants