Repository: PrefectHQ/prefect

Issue:
Shut down PowerShell session to release server port
## Description
<!-- Please describe your issue or feature request -->
With 2.0b6, prefect server instance does not release port when shut down.

## Reproduction / Example
<!-- Please given a minimal reproduction of our issue or example of the requested feature -->
When running a Prefect server instance on Windows 10 in PowerShell:

- With 2.0b6
- If you stop the server with Ctrl-C, the port is not released 
- If you try to start a new server instance, it fails 
- You must close the PowerShell console instance (or tab in Windows Terminal) and launch another server instance in a new PowerShell console.


Discussion:
PS I can't replicate in 2.0b5
I'm not using PowerShell, but I'm getting a similar problem with git bash on Windows. The uvicorn subprocess is not shut down when closing `prefect orion start` using Ctrl-C (on beta 13) and will prevent the Orion server from being re-launched.
Actually, I'd be curious to know why the prefect orion CLI starts up the uvicorn server by running it as a subprocess command instread of spinning up an event loop and setting up uvicorn as a python module to run in that loop? I don't think it would work any differently and would avoid dealing with this kind of subprocess management. This is pretty much what [uvicorn's CLI interface](https://github.com/encode/uvicorn/blob/master/uvicorn/main.py) does anyway.
I've also encoutered this issue on Windows. For me the process doesn't stop after the first `ctrl-c` sent, however `Stopping Orion...` is logged. Hence it seems as though it's trying to shutdown but not following through. I'm running version 2.2.0.
Based on reading the code a bit and doing some light research, I think the issue might be a lack of SIGTERM on Windows. The following code is used to shut down the process, while I found the following article regarding SIGTERM on Windows.

https://github.com/PrefectHQ/prefect/blob/main/src/prefect/cli/orion.py#L149-L152

https://maruel.ca/post/python_windows_signal/

I don't know where to go further here to be honest, but I figured this might be a help if someone feels like attacking this issue.
That's really helpful... also frustrating. Thanks for that research @barskern! The SIGTERM logic was added as a response to a bunch of anyio 'errors' traces being output to the terminal when doing a CTRL-C. But given the apparent lack of support for SIGTERM, that might be better behavior for windows. I'll see what we can do.

Resolving patch:
diff --git a/src/prefect/cli/orion.py b/src/prefect/cli/orion.py
--- a/src/prefect/cli/orion.py
+++ b/src/prefect/cli/orion.py
@@ -2,7 +2,6 @@
 Command line interface for working with Orion
 """
 import os
-import signal
 import textwrap
 from functools import partial
 
@@ -32,7 +31,7 @@
     PREFECT_ORION_UI_ENABLED,
 )
 from prefect.utilities.asyncutils import run_sync_in_worker_thread
-from prefect.utilities.processutils import run_process
+from prefect.utilities.processutils import kill_on_interrupt, run_process
 
 orion_app = PrefectTyper(
     name="orion",
@@ -138,24 +137,14 @@ async def start(
             )
         )
 
-        # Explicitly handle the interrupt signal here, as it will allow us to cleanly
-        # stop the Orion uvicorn server with a SIGTERM.  Failing to do that may cause
-        # a large amount of anyio error traces on the terminal, because the SIGINT is
-        # handled by Typer/Click in this (the parent process) and will start
-        # shutting down subprocesses:
+        # Explicitly handle the interrupt signal here, as it will allow us to
+        # cleanly stop the Orion uvicorn server. Failing to do that may cause a
+        # large amount of anyio error traces on the terminal, because the
+        # SIGINT is handled by Typer/Click in this process (the parent process)
+        # and will start shutting down subprocesses:
         # https://github.com/PrefectHQ/orion/issues/2475
-        # The first interrupt with send a SIGTERM to uvicorn, then subsequent ones will
-        # send SIGKILL
-        def stop_orion(*args):
-            app.console.print("\nStopping Orion...")
-            os.kill(orion_process_id, signal.SIGTERM)
-            signal.signal(signal.SIGINT, kill_orion)
-
-        def kill_orion(*args):
-            app.console.print("\nKilling Orion...")
-            os.kill(orion_process_id, signal.SIGKILL)
-
-        signal.signal(signal.SIGINT, stop_orion)
+
+        kill_on_interrupt(orion_process_id, "Orion", app.console.print)
 
     app.console.print("Orion stopped!")
 
diff --git a/src/prefect/utilities/processutils.py b/src/prefect/utilities/processutils.py
--- a/src/prefect/utilities/processutils.py
+++ b/src/prefect/utilities/processutils.py
@@ -1,8 +1,10 @@
+import os
+import signal
 import subprocess
 import sys
 from contextlib import asynccontextmanager
 from io import TextIOBase
-from typing import List, Optional, TextIO, Tuple, Union
+from typing import Callable, List, Optional, TextIO, Tuple, Union
 
 import anyio
 import anyio.abc
@@ -115,3 +117,30 @@ async def stream_text(source: TextReceiveStream, sink: Optional[TextSink]):
             pass  # Consume the item but perform no action
         else:
             raise TypeError(f"Unsupported sink type {type(sink).__name__}")
+
+
+def kill_on_interrupt(pid: int, process_name: str, print_fn: Callable):
+    """Kill a process with the given `pid` when a SIGNINT is received."""
+
+    # In a non-windows enviornment first interrupt with send a SIGTERM, then
+    # subsequent interrupts will send SIGKILL. In Windows we use
+    # CTRL_BREAK_EVENT as SIGTERM is useless:
+    # https://bugs.python.org/issue26350
+    if sys.platform == "win32":
+
+        def stop_process(*args):
+            print_fn(f"\nStopping {process_name}...")
+            os.kill(pid, signal.CTRL_BREAK_EVENT)
+
+    else:
+
+        def stop_process(*args):
+            print_fn(f"\nStopping {process_name}...")
+            os.kill(pid, signal.SIGTERM)
+            signal.signal(signal.SIGINT, kill_process)
+
+        def kill_process(*args):
+            print_fn(f"\nKilling {process_name}...")
+            os.kill(pid, signal.SIGKILL)
+
+    signal.signal(signal.SIGINT, stop_process)


Test patch:
diff --git a/tests/utilities/test_processutils.py b/tests/utilities/test_processutils.py
--- a/tests/utilities/test_processutils.py
+++ b/tests/utilities/test_processutils.py
@@ -1,8 +1,10 @@
+import signal
 import sys
+from unittest import mock
 
 import pytest
 
-from prefect.utilities.processutils import run_process
+from prefect.utilities.processutils import kill_on_interrupt, run_process
 
 
 async def test_run_process_hides_output(capsys):
@@ -52,3 +54,63 @@ async def test_run_process_allows_stderr_fd(tmp_path):
         )
     assert process.returncode == 0
     assert (tmp_path / "output.txt").read_text().strip() == "hello world"
+
+
+class TestKillOnInterrupt:
+    @pytest.mark.skipif(
+        sys.platform == "win32",
+        reason="SIGTERM is only used in non-Windows environments",
+    )
+    def test_sends_sigterm_then_sigkill(self, monkeypatch):
+        print_fn = mock.Mock()
+        os_kill = mock.Mock()
+        signal_receiver = mock.Mock()
+        monkeypatch.setattr("os.kill", os_kill)
+        monkeypatch.setattr("signal.signal", signal_receiver)
+
+        kill_on_interrupt(123, "My Process", print_fn)
+
+        # This is a bit convoluted, but the point of the `signal_receiver` mock
+        # is to try to avoid actually sending the SIGINT. Doing so causes
+        # pytest to receive the signal and cancel the test run. Note that this
+        # doesn't happen with `signal.raise_signal`, but that's unavalable in
+        # python 3.7.
+        assert signal_receiver.call_args_list[0][0][0] == signal.SIGINT
+        signal_handler = signal_receiver.call_args_list[0][0][1]
+        signal_receiver.reset_mock()
+
+        # Call SIGINT handler expect a SIGTERM
+        signal_handler()
+        print_fn.assert_called_once_with("\nStopping My Process...")
+        os_kill.assert_called_once_with(123, signal.SIGTERM)
+
+        # Reset mocks and send second SIGINT and expect SIGKILL
+        print_fn.reset_mock()
+        os_kill.reset_mock()
+
+        assert signal_receiver.call_args_list[0][0][0] == signal.SIGINT
+        signal_handler = signal_receiver.call_args_list[0][0][1]
+        signal_handler()
+
+        print_fn.assert_called_once_with("\nKilling My Process...")
+        os_kill.assert_called_once_with(123, signal.SIGKILL)
+
+    @pytest.mark.skipif(
+        sys.platform != "win32",
+        reason="CTRL_BREAK_EVENT is only defined in Windows",
+    )
+    def test_sends_ctrl_break_win32(self, monkeypatch):
+        print_fn = mock.Mock()
+        os_kill = mock.Mock()
+        signal_receiver = mock.Mock()
+        monkeypatch.setattr("os.kill", os_kill)
+        monkeypatch.setattr("signal.signal", signal_receiver)
+
+        kill_on_interrupt(123, "My Process", print_fn)
+
+        assert signal_receiver.call_args_list[0][0][0] == signal.SIGINT
+        signal_handler = signal_receiver.call_args_list[0][0][1]
+        signal_handler()
+
+        print_fn.assert_called_once_with("\nStopping My Process...")
+        os_kill.assert_called_once_with(123, signal.CTRL_BREAK_EVENT)