diff --git a/marimo/_cli/export/_common.py b/marimo/_cli/export/_common.py index 2458a5748b3..556b8f7ca19 100644 --- a/marimo/_cli/export/_common.py +++ b/marimo/_cli/export/_common.py @@ -71,11 +71,19 @@ def terminate(signum: int, _frame: object) -> None: try: if threading.current_thread() is threading.main_thread(): - for name in ("SIGTERM", "SIGHUP"): + for name in ("SIGINT", "SIGTERM", "SIGHUP"): signum = getattr(signal, name, None) + # Python 3.10 raises KeyboardInterrupt synchronously; defer it + # past Popen just like termination. Python 3.11+ asyncio.run + # already installs a cancelling handler, which we preserve. + default_handler = ( + signal.default_int_handler + if name == "SIGINT" + else signal.SIG_DFL + ) if ( signum is not None - and signal.getsignal(signum) == signal.SIG_DFL + and signal.getsignal(signum) == default_handler ): previous[signum] = signal.signal(signum, terminate) yield @@ -84,6 +92,8 @@ def terminate(signum: int, _frame: object) -> None: for signum, handler in previous.items(): signal.signal(signum, handler) # The command may finish before queued cancellation is delivered. + if termination_signal == signal.SIGINT: + raise KeyboardInterrupt from None if termination_signal is not None: raise SystemExit(128 + termination_signal) from None diff --git a/tests/_cli/test_export_subprocess.py b/tests/_cli/test_export_subprocess.py index 3e738c98ed2..849bc3f7025 100644 --- a/tests/_cli/test_export_subprocess.py +++ b/tests/_cli/test_export_subprocess.py @@ -250,7 +250,7 @@ async def interrupted_cleanup( @pytest.mark.skipif(os.name == "nt", reason="POSIX termination signals") -@pytest.mark.parametrize("signal_name", ["SIGTERM", "SIGHUP"]) +@pytest.mark.parametrize("signal_name", ["SIGINT", "SIGTERM", "SIGHUP"]) def test_export_termination_during_process_creation(signal_name: str) -> None: code = """ import asyncio, json, signal, subprocess, sys @@ -272,9 +272,16 @@ async def main(): with _export_termination_signals(): await run_command([sys.executable, '-c', 'import time; time.sleep(30)']) +loop = asyncio.new_event_loop() try: - asyncio.run(main()) + # Do not use asyncio.run here. On Python 3.11+, Runner installs its own + # SIGINT handler, which deliberately bypasses the default-handler branch + # that protects Python 3.10 from an interrupt during Popen. + loop.run_until_complete(main()) +except KeyboardInterrupt: + sys.exit(1) finally: + loop.close() print(json.dumps([process.poll() is None for process in processes])) for process in processes: if process.poll() is None: @@ -287,9 +294,9 @@ async def main(): text=True, timeout=10, ) - assert completed.returncode == 128 + getattr(signal, signal_name), ( - completed.stderr - ) + assert completed.returncode == ( + 1 if signal_name == "SIGINT" else 128 + getattr(signal, signal_name) + ), completed.stderr assert json.loads(completed.stdout) == [False]