Skip to content

fix(prompts): a cancelled prompt is never silent — one helper, exit 0, a visible note - #410

Merged
LukasWodka merged 1 commit into
developfrom
fix/cancel-visible-feedback-1253
Jul 27, 2026
Merged

fix(prompts): a cancelled prompt is never silent — one helper, exit 0, a visible note#410
LukasWodka merged 1 commit into
developfrom
fix/cancel-visible-feedback-1253

Conversation

@LukasWodka

@LukasWodka LukasWodka commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Ctrl-C at an interactive prompt exited 0 with nothing printed at two sites. mapClientErr mapped the cancellation sentinel straight to nil with no Printer call, so:

  • client create — Ctrl-C at "Provision this client?" printed the Review block, then nothing. A script could not tell it apart from a completed provision. The very next branch (an explicit "No") did print Cancelled.
  • delete — Ctrl-C while typing the client name printed the whole irreversible "This will remove …" preview, then nothing, while the mismatched-name branch just below printed Cancelled — the name didn't match. Nothing was removed.

Rather than duplicate the existing pattern a fifth and sixth time, the convention is now centralised.

Exit-code decision: 0, not 130 — and the docs were self-contradictory

Decision: exit 0 with a visible Cancelled — … line. Evidence from the code, not assumption:

All 6 prompt-cancellation sites (resources_set.go ×3, data_delete.go ×2, data_ingest_local.go, data_ingest_cluster.go) return nilexit 0 — unanimous, no inconsistency between them
All 6 exitInterrupted (130) sites (seal.go ×2, upgrade.go, auth.go, client_status.go, prepare_host.go) every one is a SIGINT / child-exit-130 that cuts short work already in flight — none is at a prompt
exitcodes.go exitOK already documented "a guided run the user cancelled cleanly" as 0

So the two categories were already cleanly separated in behaviour. What was wrong was the documentation: exitInterrupted's comment claimed "the user hit Ctrl-C at an interactive prompt", and docs/troubleshooting.md had the same claim in the 130 row — directly contradicting its own 0 row two lines above, and contradicting all twelve call sites. Both are corrected here (comment + user-facing table row) to say what 130 actually means. Exit codes are frozen as a scripting contract, so no numeric value changed.

Approach

mapClientErr had 3 non-test callers (client.go, delete.go, resources_set.go), so changing its signature was safe:

  • cleanCancel(p, nothing, …) — the one place a cancellation is reported. Prints Cancelled — <nothing>. and returns the clean exit. The Cancelled — prefix lives in the helper, so no site can invent its own wording.
  • mapPromptErr(p, err, nothing, …) replaces mapClientErr, and moves next to the sentinel in interactive.go. The Printer and the note are in the signature on purpose: the silent-return nil shape is no longer expressible — to handle the sentinel at all you have to say what didn't happen. Non-cancel errors still map to exit 1.
  • All six sites route through it, including the declined-answer twins, so Ctrl-C and "no" cannot drift apart again. The two sites whose non-cancel error needs a different code (resources set → exit 2 validation passthrough, data delete → exit 3) keep their own errors.Is check and call cleanCancel directly — the printing still funnels through one place.
  • Output is byte-identical at the four sites that were already correct. The only copy change is client create's terse Cancelled.Cancelled — nothing was provisioned., matching the other five.
  • Bonus: because cleanCancel is a printf wrapper, go vet now format-checks every call site.

Tone follows classifyPushOutcome — name the outcome honestly and say what state the user is in, rather than letting a clean exit code imply success.

Test

internal/cli/cancel_test.go — table-driven over every prompt a user can back out of, asserting exit code + user-visible output + no side effect (per the convention proposed in tracebloc/backend#930). Rows pair each Ctrl-C path with its declined-answer twin, which is what pins the consistency:

Row How Asserted line
client create Ctrl-C at the confirm Cancelled — nothing was provisioned.
client create answered "No" Cancelled — nothing was provisioned.
delete Ctrl-C while typing the name Cancelled — nothing was removed.
delete typed a name that didn't match Cancelled — the name didn't match. Nothing was removed.

Mutation-checked: reinstating return nil at the two sites fails exactly the two Ctrl-C rows ("printed no cancellation note — a silent exit 0 is indistinguishable from success"), while the declined-answer twins keep passing — i.e. the test reproduces the reported inconsistency. TestMapClientErr became TestMapPromptErr and now asserts the note is printed, not just that the error is nil.

Copy catalog

Regenerated (TB_UPDATE_GOLDEN=1 go test ./internal/cli/ -run TestCopyCatalog). The harvester learned about the new copy helper — it now assembles Cancelled — + the note argument, so every cancellation line stays reviewable in the catalog instead of degrading to a bare %s. Diff is 4 lines: the pre-existing four lines unchanged, "Cancelled.""Cancelled — nothing was provisioned.", plus "Cancelled — nothing was removed.".

make ci

All green (vet, test -race ×17 packages, lint, fmt-check, schema-check, vulncheck, file-budget, deadcode, check-style). One pre-existing advisory, unrelated and unchanged: deadcode reports a stale allowlist entry for internal/doctor/doctor.go: Status.String — verified identical on a clean tree.

Notes / one contradiction with the report

The ticket lists data_ingest_cluster.go among the sites that print. It doesn't — existingTableAction returns (false, nil) and its caller prints the note for both the decline and the Ctrl-C. Behaviour was already correct, so this is a nit, but the fold is worth knowing about when reading that site. Also worth noting: in production client create's Ctrl-C wasn't quite zero output — the deferred log line still printed a bare full log: <path>, which reads more like something happened than like an abort.

Found in tracebloc/cli#409. Second-most-common Bugbot finding class in the org ("silent failure / dishonest outcome reporting", 60 of 103 findings in this repo) and a learned Bugbot rule flagged 5 times.

Fixes tracebloc/backend#1253


Note

Low Risk
UX and scripting-contract changes only—no auth, cluster mutations, or frozen exit code values changed; behavior is covered by new cancellation tests.

Overview
Interactive prompt cancellations are no longer silent. Ctrl-C or declining at a question now always prints a Cancelled — … line and exits 0 (nothing had started). cleanCancel and mapPromptErr in interactive.go replace mapClientErr, which could return nil without printing—so scripts could mistake an abort for success (notably client create and delete).

All prompting flows (data ingest, data delete, resources set, client create, offboard delete) route declines and Ctrl-C through the same helpers so wording and exit behavior stay paired.

Docs and comments now separate prompt cancel (exit 0 + visible note) from exitInterrupted (130) for Ctrl-C during work already running (login wait, seal check, upgrade, etc.). docs/troubleshooting.md matches that split.

cancel_test.go table-tests exit code, output, and no side effects. Copy-catalog harvesting includes assembled Cancelled — lines from the new helpers; golden strings updated.

Reviewed by Cursor Bugbot for commit 396793e. Bugbot is set up for automated code reviews on this repo. Configure here.

…, a visible note

Ctrl-C at `client create`'s "Provision this client?" confirm and at
`delete`'s typed-name confirmation exited 0 having printed nothing about
it: `mapClientErr` mapped errInteractiveCancelled straight to nil with no
Printer call. A script could not tell an aborted run from a completed one,
and the declined-answer branch sitting directly beside each one DID print.

Centralise the convention instead of duplicating it a fifth and sixth time:

- `cleanCancel(p, nothing, …)` is now the only place a cancellation is
  reported — it prints "Cancelled — <nothing>." and returns the clean exit.
- `mapClientErr` becomes `mapPromptErr(p, err, nothing, …)`: the Printer and
  the note are in the signature, so the silent-return shape is unreachable.
  Non-cancel errors still map to exit 1.
- All six prompt sites route their cancellation through it, including the
  declined-answer twins, so Ctrl-C and "no" cannot drift apart. Output is
  byte-identical at the four sites that were already correct; `client
  create`'s terse "Cancelled." becomes "Cancelled — nothing was
  provisioned."

Exit code stays 0, which is what all six sites already did and what
exitOK documents. exitInterrupted (130) is used only for a Ctrl-C that cuts
short work already in flight (sign-in wait, status --wait, the seal suite,
an installer re-run) — its comment claimed the prompt case too,
contradicting exitOK and every call site, so fix that and the matching row
in docs/troubleshooting.md.

Tests: cancel_test.go is a table over every prompt a user can back out of,
asserting the exit code, the user-visible line, and that the command did
not act anyway. The copy-catalog harvester learns about the new copy helper
so the assembled "Cancelled — …" lines stay in the catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LukasWodka LukasWodka self-assigned this Jul 25, 2026
@LukasWodka

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 396793e. Configure here.

@LukasWodka

Copy link
Copy Markdown
Contributor Author

Independent verification of the exit-code call

I re-derived this from origin/develop before reviewing the fix, and it holds — with the twist that the contradiction was inside a single file:

// exitOK: success. Includes --dry-run completing, a guided run the
// user cancelled cleanly, and doctor passing with warnings only.
exitOK = 0
...
// exitInterrupted: the user hit Ctrl-C at an interactive prompt
// (128+SIGINT, the shell convention). ...
exitInterrupted = 130

Those two comments sit a few lines apart in exitcodes.go and say opposite things about the same event. The code was never ambiguous: every real exitInterrupted site is SIGINT cutting short work already in flight — auth.go:156 and client_status.go:187 (both annotated "Ctrl-C: exit quietly (no 'Error: context canceled')"), prepare_host.go:208, seal.go:100/:134, upgrade.go:130. None is at a prompt.

So fixing the two doc lines rather than the numbers is the right call — the numeric values are a scripting contract and changing one would break callers for a comment's sake.

Two corrections to the brief I gave, both worth recording:

  1. data_ingest_cluster.go:339 was listed as a site that prints. It doesn'texistingTableAction returns (false, nil) and its caller at line 91 prints, for both the declined and the cancelled path. Behaviour was already correct; only the fold is confusing to read.
  2. client create's Ctrl-C was not strictly silent. The deferred install-log branch still emitted a bare full log: <path>, which is arguably worse than nothing — it reads as though something ran.

On the design

mapPromptErr(p, err, nothing, …) taking the Printer and the note in its signature is the part that makes this durable: the silent return nil shape is no longer expressible, so the next prompt someone adds cannot reproduce this bug without deleting a parameter. go vet printf-checking every call site as a side effect is a nice bonus.

The mutation check is what I'd want to see on any test of this class — reinstating return nil fails exactly the two Ctrl-C rows while their declined-answer twins still pass. That proves the test detects this defect rather than merely covering the lines. Worth copying as the reference for the "silent failure / dishonest outcome reporting" convention proposed in tracebloc/backend#930, which is the org's second-largest finding class (103 findings, 60 in this repo).

@LukasWodka
LukasWodka requested a review from saadqbal July 27, 2026 06:36
@LukasWodka
LukasWodka merged commit 550979a into develop Jul 27, 2026
25 checks passed
@LukasWodka
LukasWodka deleted the fix/cancel-visible-feedback-1253 branch July 27, 2026 07:59
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