task: metadata: install: pip install -e .[dev]
patch: diff --git a/onlinejudge_command/utils.py b/onlinejudge_command/utils.py
index de82639..02e867a 100644
--- a/onlinejudge_command/utils.py
+++ b/onlinejudge_command/utils.py
@@ -77,8 +77,13 @@ def exec_command(command_str: str, *, stdin: Optional[IO[Any]] = None, input: Op
         try:
             answer, _ = proc.communicate(input=input, timeout=timeout)
         except subprocess.TimeoutExpired:
+            pass
+        finally:
             if preexec_fn is not None:
-                os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
+                try:
+                    os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
+                except ProcessLookupError:
+                    pass
             else:
                 proc.terminate()
 

problem_statement: Zombie processes remains when terminate oj test with Ctrl-C
repo: online-judge-tools/oj
test_cmd: pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
test_patch: diff --git a/tests/command_test.py b/tests/command_test.py
index f5d7b25..d10aa46 100644
--- a/tests/command_test.py
+++ b/tests/command_test.py
@@ -3,7 +3,9 @@ import os
 import pathlib
 import random
 import shutil
+import signal
 import sys
+import threading
 import unittest
 
 import tests.utils
@@ -1011,7 +1013,7 @@ class TestTest(unittest.TestCase):
         )
 
     @unittest.skipIf(os.name == 'nt', "procfs is required")
-    def test_call_test_check_no_zombie(self):
+    def test_call_test_check_no_zombie_with_tle(self):
         marker = 'zombie-%08x' % random.randrange(2**32)
         data = self.snippet_call_test(
             args=['-c', tests.utils.python_c("import time; time.sleep(100)  # {}".format(marker)), '--tle', '1'],
@@ -1048,6 +1050,35 @@ class TestTest(unittest.TestCase):
             with open(str(cmdline), 'rb') as fh:
                 self.assertNotIn(marker.encode(), fh.read())
 
+    @unittest.skipIf(os.name == 'nt', "procfs is required")
+    def test_call_test_check_no_zombie_with_keyboard_interrupt(self):
+        marker_for_callee = 'zombie-%08x' % random.randrange(2**32)
+        marker_for_caller = 'zombie-%08x' % random.randrange(2**32)
+        files = [
+            {
+                'path': 'test/{}-1.in'.format(marker_for_caller),
+                'data': 'foo\n'
+            },
+        ]
+
+        def send_keyboard_interrupt():
+            for cmdline in pathlib.Path('/proc').glob('*/cmdline'):
+                with open(str(cmdline), 'rb') as fh:
+                    if marker_for_caller.encode() in fh.read():
+                        pid = int(cmdline.parts[2])
+                        print('sending Ctrl-C to', pid)
+                        os.kill(pid, signal.SIGINT)
+
+        timer = threading.Timer(1.0, send_keyboard_interrupt)
+        timer.start()
+        result = tests.utils.run_in_sandbox(args=['-v', 'test', '-c', tests.utils.python_c("import time; time.sleep(10)  # {}".format(marker_for_callee)), 'test/{}-1.in'.format(marker_for_caller)], files=files)
+        self.assertNotEqual(result['proc'].returncode, 0)
+
+        # check there are no processes whose command-line arguments contains the marker word
+        for cmdline in pathlib.Path('/proc').glob('*/cmdline'):
+            with open(str(cmdline), 'rb') as fh:
+                self.assertNotIn(marker_for_callee.encode(), fh.read())
+
 
 @unittest.skipIf(os.name == 'nt', "memory checking is disabled and output is different with Linux")
 class TestTestLog(unittest.TestCase):