diff options
| author | Gavin Mak <gavinmak@google.com> | 2026-05-11 15:56:08 -0700 |
|---|---|---|
| committer | gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com> | 2026-05-12 10:41:47 -0700 |
| commit | d5037230e94e50c30698f9dd8d9404e293e23c7e (patch) | |
| tree | 3e91817d0984bdef99f2fdc4f982675de328f5c0 | |
| parent | 7cc99b24c1869d54168708a2df6e6f936c36f980 (diff) | |
| download | git-repo-d5037230e94e50c30698f9dd8d9404e293e23c7e.tar.gz | |
sync: Re-raise KeyboardInterrupt in main process
When running sync -j1, worker functions run directly in the main
process. Swallowing KeyboardInterrupt causes the loop to continue to the
next project instead of aborting.
Re-raise KeyboardInterrupt if running in the MainProcess, while
maintaining the suppression of stack traces in worker processes.
Bug: 468170157
Change-Id: I156d66bc209a265f7fa25eea0eb88737d1b51a34
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/581342
Commit-Queue: Gavin Mak <gavinmak@google.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
Tested-by: Gavin Mak <gavinmak@google.com>
| -rw-r--r-- | command.py | 5 | ||||
| -rw-r--r-- | subcmds/sync.py | 8 | ||||
| -rw-r--r-- | tests/test_subcmds_sync.py | 50 |
3 files changed, 63 insertions, 0 deletions
diff --git a/command.py b/command.py index e74a94fa3..ee1f68c14 100644 --- a/command.py +++ b/command.py | |||
| @@ -101,6 +101,11 @@ class Command: | |||
| 101 | def WantPager(self, _opt): | 101 | def WantPager(self, _opt): |
| 102 | return False | 102 | return False |
| 103 | 103 | ||
| 104 | @staticmethod | ||
| 105 | def is_multiprocessing_active() -> bool: | ||
| 106 | """Whether the current process is a worker in a pool.""" | ||
| 107 | return multiprocessing.current_process().name != "MainProcess" | ||
| 108 | |||
| 104 | def ReadEnvironmentOptions(self, opts): | 109 | def ReadEnvironmentOptions(self, opts): |
| 105 | """Set options from environment variables.""" | 110 | """Set options from environment variables.""" |
| 106 | 111 | ||
diff --git a/subcmds/sync.py b/subcmds/sync.py index b1c30867e..2517694e7 100644 --- a/subcmds/sync.py +++ b/subcmds/sync.py | |||
| @@ -841,6 +841,8 @@ later is required to fix a server side protocol bug. | |||
| 841 | ) | 841 | ) |
| 842 | except KeyboardInterrupt: | 842 | except KeyboardInterrupt: |
| 843 | logger.error("Keyboard interrupt while processing %s", project.name) | 843 | logger.error("Keyboard interrupt while processing %s", project.name) |
| 844 | if not cls.is_multiprocessing_active(): | ||
| 845 | raise | ||
| 844 | except GitError as e: | 846 | except GitError as e: |
| 845 | logger.error("error.GitError: Cannot fetch %s", e) | 847 | logger.error("error.GitError: Cannot fetch %s", e) |
| 846 | errors.append(e) | 848 | errors.append(e) |
| @@ -1104,6 +1106,8 @@ later is required to fix a server side protocol bug. | |||
| 1104 | errors.extend(syncbuf.errors) | 1106 | errors.extend(syncbuf.errors) |
| 1105 | except KeyboardInterrupt: | 1107 | except KeyboardInterrupt: |
| 1106 | logger.error("Keyboard interrupt while processing %s", project.name) | 1108 | logger.error("Keyboard interrupt while processing %s", project.name) |
| 1109 | if not cls.is_multiprocessing_active(): | ||
| 1110 | raise | ||
| 1107 | except GitError as e: | 1111 | except GitError as e: |
| 1108 | logger.error( | 1112 | logger.error( |
| 1109 | "error.GitError: Cannot checkout %s: %s", project.name, e | 1113 | "error.GitError: Cannot checkout %s: %s", project.name, e |
| @@ -2410,6 +2414,8 @@ later is required to fix a server side protocol bug. | |||
| 2410 | logger.error( | 2414 | logger.error( |
| 2411 | "Keyboard interrupt while processing %s", project.name | 2415 | "Keyboard interrupt while processing %s", project.name |
| 2412 | ) | 2416 | ) |
| 2417 | if not cls.is_multiprocessing_active(): | ||
| 2418 | raise | ||
| 2413 | except GitError as e: | 2419 | except GitError as e: |
| 2414 | fetch_errors.append(e) | 2420 | fetch_errors.append(e) |
| 2415 | logger.error("error.GitError: Cannot fetch %s", e) | 2421 | logger.error("error.GitError: Cannot fetch %s", e) |
| @@ -2460,6 +2466,8 @@ later is required to fix a server side protocol bug. | |||
| 2460 | logger.error( | 2466 | logger.error( |
| 2461 | "Keyboard interrupt while processing %s", project.name | 2467 | "Keyboard interrupt while processing %s", project.name |
| 2462 | ) | 2468 | ) |
| 2469 | if not cls.is_multiprocessing_active(): | ||
| 2470 | raise | ||
| 2463 | except GitError as e: | 2471 | except GitError as e: |
| 2464 | checkout_errors.append(e) | 2472 | checkout_errors.append(e) |
| 2465 | logger.error( | 2473 | logger.error( |
diff --git a/tests/test_subcmds_sync.py b/tests/test_subcmds_sync.py index 0a46bbdf3..669027c98 100644 --- a/tests/test_subcmds_sync.py +++ b/tests/test_subcmds_sync.py | |||
| @@ -477,6 +477,56 @@ class GetPreciousObjectsState(unittest.TestCase): | |||
| 477 | ) | 477 | ) |
| 478 | 478 | ||
| 479 | 479 | ||
| 480 | class KeyboardInterruptTest(unittest.TestCase): | ||
| 481 | """Tests for KeyboardInterrupt handling in Sync operations.""" | ||
| 482 | |||
| 483 | def setUp(self): | ||
| 484 | self.project = mock.MagicMock(name="project") | ||
| 485 | self.project.name = "project" | ||
| 486 | self.project.relpath = "proj" | ||
| 487 | self.project.manifest.IsArchive = False | ||
| 488 | self.opt = mock.Mock() | ||
| 489 | self.opt.quiet = True | ||
| 490 | self.opt.verbose = False | ||
| 491 | self.opt.tags = False | ||
| 492 | |||
| 493 | self.sync_dict = {} | ||
| 494 | |||
| 495 | self.get_parallel_context_mock = { | ||
| 496 | "projects": [self.project], | ||
| 497 | "sync_dict": self.sync_dict, | ||
| 498 | "ssh_proxy": None, | ||
| 499 | } | ||
| 500 | |||
| 501 | @mock.patch("subcmds.sync.Sync.is_multiprocessing_active") | ||
| 502 | def test_fetch_one_keyboard_interrupt_main_process(self, mock_is_active): | ||
| 503 | """Test that _FetchOne re-raises KeyboardInterrupt if not worker.""" | ||
| 504 | mock_is_active.return_value = False | ||
| 505 | self.project.Sync_NetworkHalf.side_effect = KeyboardInterrupt() | ||
| 506 | |||
| 507 | with mock.patch.object( | ||
| 508 | sync.Sync, | ||
| 509 | "get_parallel_context", | ||
| 510 | return_value=self.get_parallel_context_mock, | ||
| 511 | ): | ||
| 512 | with self.assertRaises(KeyboardInterrupt): | ||
| 513 | sync.Sync._FetchOne(self.opt, 0) | ||
| 514 | |||
| 515 | @mock.patch("subcmds.sync.Sync.is_multiprocessing_active") | ||
| 516 | def test_fetch_one_keyboard_interrupt_worker_process(self, mock_is_active): | ||
| 517 | """Test that _FetchOne suppresses KeyboardInterrupt in workers.""" | ||
| 518 | mock_is_active.return_value = True | ||
| 519 | self.project.Sync_NetworkHalf.side_effect = KeyboardInterrupt() | ||
| 520 | |||
| 521 | with mock.patch.object( | ||
| 522 | sync.Sync, | ||
| 523 | "get_parallel_context", | ||
| 524 | return_value=self.get_parallel_context_mock, | ||
| 525 | ): | ||
| 526 | result = sync.Sync._FetchOne(self.opt, 0) | ||
| 527 | self.assertFalse(result.success) | ||
| 528 | |||
| 529 | |||
| 480 | class CheckForBloatedProjects(unittest.TestCase): | 530 | class CheckForBloatedProjects(unittest.TestCase): |
| 481 | """Tests for Sync._CheckForBloatedProjects.""" | 531 | """Tests for Sync._CheckForBloatedProjects.""" |
| 482 | 532 | ||
