Repository: borgbackup/borg

Issue:
ctrl-c does not work as intended when using a remote repo
## Feature
borg has "ctrl-c/SIGINT support" for some operations since a while.

the idea is that if a user presses ctrl-c **once**, borg will intercept that, write some sort of "checkpoint" (so it does not have to start all over from the beginning, but saves the progress it has made) and then terminate.

## The issue
While working on #6887 i noticed that this seems not to work with a remote (`ssh://`) repo:
- ctrl-c key combo gets detected by the terminal and it sends a SIGINT to all processes in the process group (borg and ssh client invoked by borg)
- borg receives the SIGINT, catches it and likely would want to do a checkpoint...
- ssh also receives the SIGINT and terminates, breaking the pipe to borg, breaking the connection to the repo
- borg notices that (broken pipe / connection closed by remote) and terminates due to the connection being closed
- borg does not get to doing the checkpoint

## Workaround
When using `kill -INT <BORG_PID>` to send a SIGINT to the borg process **only** (and not to ssh), it works as intended.


Discussion:
https://stackoverflow.com/questions/46921501/trap-ctrl-c-run-a-function-but-dont-exit-subprocess

maybe we could:
- temporarily ignore SIGINT
- create the subprocess (which then also ignores SIGINT according to that SO answer)
- un-ignore SIGINT (restore previous handler)

The pty stuff sounds like more effort and might be OS specific.
https://stackoverflow.com/questions/5045771/python-how-to-prevent-subprocesses-from-receiving-ctrl-c-control-c-sigint

Resolving patch:
diff --git a/src/borg/archiver.py b/src/borg/archiver.py
--- a/src/borg/archiver.py
+++ b/src/borg/archiver.py
@@ -73,7 +73,7 @@
     from .helpers import umount
     from .helpers import flags_root, flags_dir, flags_special_follow, flags_special
     from .helpers import msgpack
-    from .helpers import sig_int
+    from .helpers import sig_int, ignore_sigint
     from .helpers import iter_separated
     from .helpers import get_tar_filter
     from .nanorst import rst_to_terminal
@@ -527,7 +527,7 @@ def create_inner(archive, cache, fso):
                 if not dry_run:
                     try:
                         try:
-                            proc = subprocess.Popen(args.paths, stdout=subprocess.PIPE)
+                            proc = subprocess.Popen(args.paths, stdout=subprocess.PIPE, preexec_fn=ignore_sigint)
                         except (FileNotFoundError, PermissionError) as e:
                             self.print_error('Failed to execute command: %s', e)
                             return self.exit_code
@@ -546,7 +546,7 @@ def create_inner(archive, cache, fso):
                 paths_sep = eval_escapes(args.paths_delimiter) if args.paths_delimiter is not None else '\n'
                 if args.paths_from_command:
                     try:
-                        proc = subprocess.Popen(args.paths, stdout=subprocess.PIPE)
+                        proc = subprocess.Popen(args.paths, stdout=subprocess.PIPE, preexec_fn=ignore_sigint)
                     except (FileNotFoundError, PermissionError) as e:
                         self.print_error('Failed to execute command: %s', e)
                         return self.exit_code
diff --git a/src/borg/helpers/process.py b/src/borg/helpers/process.py
--- a/src/borg/helpers/process.py
+++ b/src/borg/helpers/process.py
@@ -228,6 +228,19 @@ def __exit__(self, exception_type, exception_value, traceback):
 sig_int = SigIntManager()
 
 
+def ignore_sigint():
+    """
+    Ignore SIGINT, see also issue #6912.
+
+    Ctrl-C will send a SIGINT to both the main process (borg) and subprocesses
+    (e.g. ssh for remote ssh:// repos), but often we do not want the subprocess
+    getting killed (e.g. because it is still needed to cleanly shut down borg).
+
+    To avoid that: Popen(..., preexec_fn=ignore_sigint)
+    """
+    signal.signal(signal.SIGINT, signal.SIG_IGN)
+
+
 def popen_with_error_handling(cmd_line: str, log_prefix='', **kwargs):
     """
     Handle typical errors raised by subprocess.Popen. Return None if an error occurred,
@@ -315,10 +328,10 @@ def create_filter_process(cmd, stream, stream_close, inbound=True):
         # for us to do something while we block on the process for something different.
         if inbound:
             proc = popen_with_error_handling(cmd, stdout=subprocess.PIPE, stdin=filter_stream,
-                                             log_prefix='filter-process: ', env=env)
+                                             log_prefix='filter-process: ', env=env, preexec_fn=ignore_sigint)
         else:
             proc = popen_with_error_handling(cmd, stdin=subprocess.PIPE, stdout=filter_stream,
-                                             log_prefix='filter-process: ', env=env)
+                                             log_prefix='filter-process: ', env=env, preexec_fn=ignore_sigint)
         if not proc:
             raise Error(f'filter {cmd}: process creation failed')
         stream = proc.stdout if inbound else proc.stdin
diff --git a/src/borg/remote.py b/src/borg/remote.py
--- a/src/borg/remote.py
+++ b/src/borg/remote.py
@@ -26,7 +26,7 @@
 from .helpers import sysinfo
 from .helpers import format_file_size
 from .helpers import safe_unlink
-from .helpers import prepare_subprocess_env
+from .helpers import prepare_subprocess_env, ignore_sigint
 from .logger import create_logger, setup_logging
 from .helpers import msgpack
 from .repository import Repository
@@ -557,7 +557,9 @@ def __init__(self, location, create=False, exclusive=False, lock_wait=None, lock
         if not testing:
             borg_cmd = self.ssh_cmd(location) + borg_cmd
         logger.debug('SSH command line: %s', borg_cmd)
-        self.p = Popen(borg_cmd, bufsize=0, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env)
+        # we do not want the ssh getting killed by Ctrl-C/SIGINT because it is needed for clean shutdown of borg.
+        # borg's SIGINT handler tries to write a checkpoint and requires the remote repo connection.
+        self.p = Popen(borg_cmd, bufsize=0, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env, preexec_fn=ignore_sigint)
         self.stdin_fd = self.p.stdin.fileno()
         self.stdout_fd = self.p.stdout.fileno()
         self.stderr_fd = self.p.stderr.fileno()


Test patch: