Skip to content

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

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

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

Conversation

@ThomasWaldmann

Copy link
Copy Markdown
Member

Forward port of #10208 to master. Fixes #8521.

While a command runs, main() wraps it in with sig_int: (archiver/init.py:647), 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 such as Pika Backup — was just remembered while input() kept waiting.

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.

Same change as #10208, adapted to helpers/yes_no.py and testsuite/helpers/yes_no_test.py.

Verification (on this branch, not just on 1.4-maint)

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
  • test_yes_sigint_aborts (new) — 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 it fails (DID NOT RAISE) without the fix.
  • test_yes_restores_sigint_handler (new) — the question must not change SIGINT handling for whatever runs after it.
  • src/borg/testsuite/helpers/ passes completely: 538 tests.
  • check_cmd_test.py, delete_cmd_test.py, repo_delete_cmd_test.py: 35 passed, 38 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 87.29%. Comparing base (856cd77) to head (29022db).
⚠️ Report is 16 commits behind head on master.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #10209      +/-   ##
==========================================
+ Coverage   87.24%   87.29%   +0.04%     
==========================================
  Files         102      102              
  Lines       18429    18444      +15     
  Branches     2832     2834       +2     
==========================================
+ Hits        16079    16101      +22     
+ Misses       1643     1638       -5     
+ Partials      707      705       -2     

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

@ThomasWaldmann
ThomasWaldmann force-pushed the ctrlc-yes-prompt-8521-master branch from 93807c4 to fb519c4 Compare August 25, 2026 13:36
@ThomasWaldmann

ThomasWaldmann commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

Updated together with #10208: the test now uses signal.raise_signal() instead of os.kill(), because a process-directed SIGINT can be delivered to another thread (e.g. on NetBSD under pytest-xdist), and then the main thread only runs the handler after yes() has restored the previous one.

Measurements and the full analysis are in #10208 (comment) — the fix itself was verified to work on NetBSD end to end. Master tests here: 538 passed.

@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), same as #10208, 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/helpers/ 540 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 force-pushed the ctrlc-yes-prompt-8521-master branch from 3f90f5b to 29022db Compare August 26, 2026 12:24
@ThomasWaldmann
ThomasWaldmann merged commit 47f7dd3 into borgbackup:master Aug 26, 2026
22 of 23 checks passed
@ThomasWaldmann
ThomasWaldmann deleted the ctrlc-yes-prompt-8521-master branch August 26, 2026 12:56
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.

Ctrl+C does not work during y/N questions

1 participant