Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions marimo/_cli/export/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Comment thread
manzt marked this conversation as resolved.
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
Expand All @@ -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

Expand Down
17 changes: 12 additions & 5 deletions tests/_cli/test_export_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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]


Expand Down
Loading