Skip to content

let Ctrl-C / SIGINT abort interactive prompts (y/n and passphrase), fixes #8521 - #10208

Merged
ThomasWaldmann merged 1 commit into
borgbackup:1.4-maintfrom
ThomasWaldmann:ctrlc-yes-prompt-8521
Aug 26, 2026
Merged

let Ctrl-C / SIGINT abort interactive prompts (y/n and passphrase), fixes #8521#10208
ThomasWaldmann merged 1 commit into
borgbackup:1.4-maintfrom
ThomasWaldmann:ctrlc-yes-prompt-8521

Conversation

@ThomasWaldmann

Copy link
Copy Markdown
Member

Fixes #8521.

While a command runs, main() wraps it in with sig_int:, so SigIntManager.handler is installed for the whole run. That handler deliberately does not raise — it only remembers that Ctrl-C was pressed, so that a running operation like borg create can still finish the archive in an orderly way.

That same handler is also active while borg waits for an answer to a y/n question. So a Ctrl-C, or a SIGINT sent by a frontend, was just remembered while input() kept waiting — which is what @sophie-h reported for Pika Backup's abort feature (they currently work around it by also writing "\n" to borg's stdin).

While waiting for an answer there is nothing to finish, so this temporarily installs the raising SIGINT handler around the input() call and restores the previous one afterwards.

Verification

Real borg process, prompt from borg check --repair, a single SIGINT sent to it:

with this change without it
borg check --repair, one SIGINT at the prompt aborts, rc 130 still running after 10 s

Two tests added next to the other yes() tests:

  • test_yes_sigint_aborts — sends SIGINT to itself from inside the input function, while sig_int is active as it is during a real command, and expects KeyboardInterrupt. Verified that it fails (DID NOT RAISE) without the fix.
  • test_yes_restores_sigint_handler — the question must not change SIGINT handling for whatever runs after it.

src/borg/testsuite/helpers.py passes completely (146 tests), and the prompt-related archiver tests (-k "repair or delete or unencrypted or check_usage or i_know") pass: 29 passed, 14 skipped.

Notes

  • Only y/n questions are affected. The passphrase prompt goes through getpass and is not touched here.
  • Non-interactive paths (prompt=False, env_var_override, EOF) are unchanged — the handler is installed only around the actual input() call.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.14%. Comparing base (c1f0125) to head (92c7f07).
⚠️ Report is 3 commits behind head on 1.4-maint.

Additional details and impacted files
@@              Coverage Diff              @@
##           1.4-maint   #10208      +/-   ##
=============================================
+ Coverage      82.06%   82.14%   +0.07%     
=============================================
  Files             38       38              
  Lines          11471    11475       +4     
  Branches        1807     1807              
=============================================
+ Hits            9414     9426      +12     
+ Misses          1470     1464       -6     
+ Partials         587      585       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

ThomasWaldmann added a commit to ThomasWaldmann/borg that referenced this pull request Aug 25, 2026
While a command runs, borg installs a SIGINT handler (SigIntManager) that only
remembers that Ctrl-C was pressed, so that a running operation like borg create
can still be finished in an orderly way. That handler is also active while borg
waits for an answer to a y/n question - so a Ctrl-C, or a SIGINT sent by a
frontend, was just remembered while input() kept waiting.

While waiting for an answer there is nothing to finish, so temporarily install
the raising SIGINT handler around the input() call. Ctrl-C / SIGINT now aborts
right away (rc 130), which is what an interactive user expects and what
frontends like Pika Backup need for their abort feature.

Forward port of borgbackup#10208 (1.4-maint).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ThomasWaldmann ThomasWaldmann changed the title yes(): let Ctrl-C / SIGINT abort a y/n question, fixes #8521 let Ctrl-C / SIGINT abort interactive prompts (y/n and passphrase), fixes #8521 Aug 26, 2026
@ThomasWaldmann

Copy link
Copy Markdown
Member Author

Extended to the passphrase prompt (second commit), because it has exactly the same problem.

Before

Measured with a real pty, an encrypted repo and no BORG_PASSPHRASE, at Enter passphrase for key ...:

before
Ctrl-C (\x03 through the tty line discipline) ignored, still waiting after 10 s
SIGINT via kill(2) (what a frontend sends) ignored, still waiting after 10 s

Cause is the same as for the y/n questions: Passphrase.getpass() wraps getpass.getpass() catching only EOFError, while borg's flag-only SigIntManager handler is installed.

After

Both cases exit with rc 130.

Terminal echo is not left disabled - getpass() restores the termios settings in a finally, also when the read raises. Measured through the whole cycle: echo while prompting: False, echo after abort: True.

borg repo-create does not leave anything behind

Worth checking explicitly, since the passphrase for a new repository is asked while the repository already exists on disk. It is fine, because do_repo_create already has:

try:
    key = key_creator(repository, args, other_key=other_key)
except (EOFError, KeyboardInterrupt):
    repository.destroy()
    raise CancelledByUser()

and the passphrase is asked before any key material is generated or saved (Passphrase.new() comes before key.save()). Interrupting a real repo-create at the prompt:

case result
repokey, first prompt rc 3 "Cancelled by user.", repo directory gone, keys directory gone
repokey, "Enter same passphrase again" rc 3 "Cancelled by user.", repo directory gone, keys directory gone
keyfile, first prompt rc 3 "Cancelled by user.", repo directory gone, keys directory gone

So no half-written key file and no orphaned repokey blob.

Tests

  • test_getpass_sigint_aborts - sends the signal from inside the patched getpass, expects KeyboardInterrupt. Verified it fails when the fix is reverted.
  • test_getpass_restores_sigint_handler - the prompt must not change SIGINT handling of what runs afterwards.
  • The tests now use their own SigIntManager() instance instead of the global sig_int. The global one can only be entered once per process (its __exit__ drops the context), so a second test using it would break as soon as two such tests run in the same worker.

Full runs: testsuite/key.py 64 passed, testsuite/helpers.py 146 passed. The y/n end-to-end check still aborts with rc 130.

Not included here

  • borg key import --paper reads its lines with plain input() too (crypto/keymanager.py), so Ctrl-C is swallowed there as well. Happy to cover it in the same PR if you want it.
  • Unrelated pre-existing bug found while testing this: at the passphrase prompt, EOF (e.g. borg repo-create < /dev/null without BORG_PASSPHRASE) is turned into NoPassphraseFailure by Passphrase.getpass(), which is not in the except (EOFError, KeyboardInterrupt) list above - so repository.destroy() is skipped and a half-created repository stays behind (A repository already exists ... on retry, Repository has no manifest. on use). Same on 1.4-maint. Tell me if you want an issue for that.

While a command runs, borg installs a SIGINT handler (SigIntManager) that only
remembers that Ctrl-C was pressed, so that a running operation like borg create
can still be finished in an orderly way. That handler is also active while borg
waits for input from the user - so a Ctrl-C, or a SIGINT sent by a frontend, was
just remembered while input() / getpass() kept waiting.

While waiting for an answer or a passphrase there is nothing to finish, so
temporarily install the raising SIGINT handler around the input call - for the
y/n questions in yes() and for the passphrase prompt.

Both abort right away with rc 130 now, which is what an interactive user expects
and what frontends like Pika Backup need for their abort feature. Terminal echo
is not left disabled: getpass() restores the termios settings in a finally
clause, also when the read raises.

borg repo-create keeps cleaning up after itself: an interrupt at the "Enter new
passphrase" prompt runs into the existing
`except (EOFError, KeyboardInterrupt): repository.destroy()`, so neither a
repository directory nor a key file is left behind (verified for the repokey and
the keyfile case, at the first and at the confirmation prompt).

The tests use their own SigIntManager instance rather than the global sig_int,
which can only be entered once per process. They send the signal with
signal.raise_signal(): os.kill() would send it to the process and some kernels
(e.g. NetBSD) then deliver it to another thread if there is one - the main
thread would only run the handler later, after the prompt restored the previous
handler, and the test would flap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ThomasWaldmann
ThomasWaldmann merged commit 651230e into borgbackup:1.4-maint Aug 26, 2026
13 of 14 checks passed
@ThomasWaldmann
ThomasWaldmann deleted the ctrlc-yes-prompt-8521 branch August 26, 2026 12:44
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.

1 participant