Repository: beeware/briefcase

Issue:
On Windows, pressing Ctrl-C during an Android `build` or `run` causes briefcase to hang
Since the recent Briefcase logging changes, I can no longer interrupt an Android `build` or `run` on Windows. @rmartin16: You might be interested in this.

The `build` command launches two java.exe processes: one Gradle client and one Gradle daemon. Pressing Ctrl-C causes the daemon process to exit, but there is no feedback in the console, and the "Building" waitbar continues to animate. Pressing Ctrl-C a second time displays the message `Terminate batch job (Y/N)?`, but entering `y` has no effect. Killing the other java.exe process (the Gradle client) has no effect either. The only way I can get back to the command line prompt is to kill both briefcase.exe and python.exe. (This happens in both the cmd and MSYS2 terminals.)

There's a similar problem when `briefcase run` is displaying the Logcat. Ctrl-C kills the adb.exe process, but there is no feedback in the console, and Briefcase continues to run until I kill it with something outside of the terminal. (This only happens for me in a cmd terminal: MSYS2 works fine.)

**Expected behavior**
In all situations, Ctrl-C should promptly shut everything down and return to the command line prompt.

**Environment:**
 - Operating System: Windows 10
 - Python version: 3.8
 - Software versions:
   - Briefcase: 8df2d112e

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#817](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (73f6367) into [main](https://codecov.io/gh/beeware/briefcase/commit/abdcef30848a94cb2bdad7be987f599d43264751?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (abdcef3) will **decrease** coverage by `0.09%`.
> The diff coverage is `81.81%`.

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/817/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `98.41% <81.81%> (-1.59%)` | :arrow_down: |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#817](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (73f6367) into [main](https://codecov.io/gh/beeware/briefcase/commit/abdcef30848a94cb2bdad7be987f599d43264751?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (abdcef3) will **decrease** coverage by `0.09%`.
> The diff coverage is `81.81%`.

> :exclamation: Current head 73f6367 differs from pull request most recent head c744656. Consider uploading reports for the commit c744656 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/817/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `98.41% <81.81%> (-1.59%)` | :arrow_down: |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#817](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (c744656) into [main](https://codecov.io/gh/beeware/briefcase/commit/abdcef30848a94cb2bdad7be987f599d43264751?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (abdcef3) will **decrease** coverage by `0.00%`.
> The diff coverage is `100.00%`.

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/817/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `100.00% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#817](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (c744656) into [main](https://codecov.io/gh/beeware/briefcase/commit/abdcef30848a94cb2bdad7be987f599d43264751?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (abdcef3) will **decrease** coverage by `0.00%`.
> The diff coverage is `100.00%`.

> :exclamation: Current head c744656 differs from pull request most recent head b8c5077. Consider uploading reports for the commit b8c5077 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/817/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `100.00% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#817](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (c744656) into [main](https://codecov.io/gh/beeware/briefcase/commit/abdcef30848a94cb2bdad7be987f599d43264751?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (abdcef3) will **decrease** coverage by `0.00%`.
> The diff coverage is `100.00%`.

> :exclamation: Current head c744656 differs from pull request most recent head c768048. Consider uploading reports for the commit c768048 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/817/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `100.00% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#817](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (b8c5077) into [main](https://codecov.io/gh/beeware/briefcase/commit/abdcef30848a94cb2bdad7be987f599d43264751?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (abdcef3) will **decrease** coverage by `0.00%`.
> The diff coverage is `100.00%`.

> :exclamation: Current head b8c5077 differs from pull request most recent head c768048. Consider uploading reports for the commit c768048 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/817/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `100.00% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#817](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (c768048) into [main](https://codecov.io/gh/beeware/briefcase/commit/abdcef30848a94cb2bdad7be987f599d43264751?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (abdcef3) will **decrease** coverage by `0.00%`.
> The diff coverage is `100.00%`.

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/817/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `100.00% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#817](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (b8c5077) into [main](https://codecov.io/gh/beeware/briefcase/commit/abdcef30848a94cb2bdad7be987f599d43264751?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (abdcef3) will **decrease** coverage by `0.00%`.
> The diff coverage is `100.00%`.

> :exclamation: Current head b8c5077 differs from pull request most recent head 0c5436e. Consider uploading reports for the commit 0c5436e to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/817/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `100.00% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#817](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (0c5436e) into [main](https://codecov.io/gh/beeware/briefcase/commit/abdcef30848a94cb2bdad7be987f599d43264751?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (abdcef3) will **decrease** coverage by `0.00%`.
> The diff coverage is `100.00%`.

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/817/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `100.00% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#817](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (0c5436e) into [main](https://codecov.io/gh/beeware/briefcase/commit/abdcef30848a94cb2bdad7be987f599d43264751?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (abdcef3) will **decrease** coverage by `0.00%`.
> The diff coverage is `100.00%`.

> :exclamation: Current head 0c5436e differs from pull request most recent head 9b80045. Consider uploading reports for the commit 9b80045 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/817/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `100.00% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#817](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (9b80045) into [main](https://codecov.io/gh/beeware/briefcase/commit/abdcef30848a94cb2bdad7be987f599d43264751?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (abdcef3) will **decrease** coverage by `0.00%`.
> The diff coverage is `100.00%`.

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/817/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `100.00% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#817](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (c768048) into [main](https://codecov.io/gh/beeware/briefcase/commit/abdcef30848a94cb2bdad7be987f599d43264751?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (abdcef3) will **decrease** coverage by `0.00%`.
> The diff coverage is `100.00%`.

> :exclamation: Current head c768048 differs from pull request most recent head 6f61a78. Consider uploading reports for the commit 6f61a78 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/817?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/817/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `100.00% <100.00%> (ø)` | |


Patch:
diff --git a/changes/809.bugfix.rst b/changes/809.bugfix.rst
new file mode 100644
index 00000000..d2539b68
--- /dev/null
+++ b/changes/809.bugfix.rst
@@ -0,0 +1,1 @@
+Python 3.7 and 3.8 on Windows will no longer deadlock when CTRL+C is sent during a subprocess command.
diff --git a/src/briefcase/integrations/subprocess.py b/src/briefcase/integrations/subprocess.py
index 59cbe0f8..547939e1 100644
--- a/src/briefcase/integrations/subprocess.py
+++ b/src/briefcase/integrations/subprocess.py
@@ -277,6 +277,7 @@ class Subprocess:
             if process.stderr and kwargs["stderr"] != subprocess.STDOUT:
                 stderr = process.stderr.read()
             return_code = process.poll()
+        self._log_return_code(return_code)
 
         if check and return_code:
             raise subprocess.CalledProcessError(return_code, args, stderr=stderr)
@@ -367,7 +368,7 @@ class Subprocess:
             [str(arg) for arg in args], **self.final_kwargs(**kwargs)
         )
 
-    def stream_output(self, label, popen_process, stop_func=None):
+    def stream_output(self, label, popen_process, stop_func=lambda: False):
         """Stream the output of a Popen process until the process exits. If the
         user sends CTRL+C, the process will be terminated.
 
@@ -385,19 +386,27 @@ class Subprocess:
             name=f"{label} output streamer",
             target=self._stream_output_thread,
             args=(popen_process,),
+            daemon=True,
         )
         try:
             output_streamer.start()
-            if stop_func:
-                while output_streamer.is_alive() and not stop_func():
-                    time.sleep(0.1)
-            else:
-                output_streamer.join()
+            # joining the thread is avoided due to demonstrated
+            # instability of thread interruption via CTRL+C (#809)
+            while not stop_func() and output_streamer.is_alive():
+                time.sleep(0.1)
         except KeyboardInterrupt:
-            pass  # allow CTRL+C to gracefully stop streaming
+            self.command.logger.info("Stopping...")
+            # allow time for CTRL+C to propagate to the child process
+            time.sleep(0.25)
         finally:
             self.cleanup(label, popen_process)
-            output_streamer.join()
+            streamer_deadline = time.time() + 3
+            while output_streamer.is_alive() and time.time() < streamer_deadline:
+                time.sleep(0.1)
+            if output_streamer.is_alive():
+                self.command.logger.error(
+                    "Log stream hasn't terminated; log output may be corrupted."
+                )
 
     def _stream_output_thread(self, popen_process):
         """Stream output for a Popen process in a Thread.
@@ -411,13 +420,7 @@ class Subprocess:
             if output_line:
                 self.command.logger.info(output_line)
             elif output_line == "":
-                # a return code will be available once the process returns one to the OS.
-                # by definition, that should mean the process has exited.
-                return_code = popen_process.poll()
-                # only return once all output has been read and the process has exited.
-                if return_code is not None:
-                    self._log_return_code(return_code)
-                    return
+                return
 
     def cleanup(self, label, popen_process):
         """Clean up after a Popen process, gracefully terminating if possible;


Test patch:
diff --git a/tests/integrations/subprocess/conftest.py b/tests/integrations/subprocess/conftest.py
index fc4da546..268e0030 100644
--- a/tests/integrations/subprocess/conftest.py
+++ b/tests/integrations/subprocess/conftest.py
@@ -1,3 +1,4 @@
+import time
 from unittest.mock import MagicMock
 
 import pytest
@@ -41,18 +42,20 @@ def mock_sub():
 @pytest.fixture
 def popen_process():
     process = MagicMock()
-    # There are extra empty strings at the end to simulate readline
-    # continuously returning "" once it reaches EOF
-    process.stdout.readline.side_effect = [
-        "output line 1\n",
-        "\n",
-        "output line 3\n",
-        "",
-        "",
-        "",
-        "",
-        "",
-        "",
-    ]
-    process.poll.side_effect = [None, None, None, -3, -3, -3]
+
+    # Mock the readline values of an actual process. The final return value is "",
+    # indicating that the process has exited; however, we insert a short sleep
+    # to ensure that any other threads will have a chance to run before this
+    # thread acutally terminates.
+    def mock_readline():
+        yield from [
+            "output line 1\n",
+            "\n",
+            "output line 3\n",
+        ]
+        time.sleep(0.1)
+        yield ""
+
+    process.stdout.readline.side_effect = mock_readline()
+    process.poll.return_value = -3
     return process
diff --git a/tests/integrations/subprocess/test_Subprocess__check_output.py b/tests/integrations/subprocess/test_Subprocess__check_output.py
index 0b965e00..bcdb1e75 100644
--- a/tests/integrations/subprocess/test_Subprocess__check_output.py
+++ b/tests/integrations/subprocess/test_Subprocess__check_output.py
@@ -187,7 +187,7 @@ def test_debug_call_with_env(mock_sub, capsys, tmp_path):
 
 def test_calledprocesserror_exception_logging(mock_sub, capsys):
     """If command errors, ensure command output is printed."""
-    mock_sub.command.logger = Log(verbosity=3)
+    mock_sub.command.logger = Log(verbosity=2)
 
     called_process_error = CalledProcessError(
         returncode=-1,
diff --git a/tests/integrations/subprocess/test_Subprocess__cleanup.py b/tests/integrations/subprocess/test_Subprocess__cleanup.py
index 58a0d9d9..1cf7dd3c 100644
--- a/tests/integrations/subprocess/test_Subprocess__cleanup.py
+++ b/tests/integrations/subprocess/test_Subprocess__cleanup.py
@@ -28,4 +28,4 @@ def test_dirty_termination(mock_sub, capsys):
     process.kill.assert_called_once_with()
 
     # Log contains a contextual message.
-    assert capsys.readouterr().out == ("Forcibly killing testing process...\n")
+    assert capsys.readouterr().out == "Forcibly killing testing process...\n"
diff --git a/tests/integrations/subprocess/test_Subprocess__run.py b/tests/integrations/subprocess/test_Subprocess__run.py
index 8b7219f1..cde51038 100644
--- a/tests/integrations/subprocess/test_Subprocess__run.py
+++ b/tests/integrations/subprocess/test_Subprocess__run.py
@@ -179,7 +179,7 @@ def test_debug_call_with_env(mock_sub, capsys, tmp_path):
 
 
 def test_calledprocesserror_exception_logging(mock_sub, capsys):
-    mock_sub.command.logger = Log(verbosity=3)
+    mock_sub.command.logger = Log(verbosity=2)
 
     called_process_error = CalledProcessError(
         returncode=-1,
diff --git a/tests/integrations/subprocess/test_Subprocess__stream_output.py b/tests/integrations/subprocess/test_Subprocess__stream_output.py
index 858ae419..202103fd 100644
--- a/tests/integrations/subprocess/test_Subprocess__stream_output.py
+++ b/tests/integrations/subprocess/test_Subprocess__stream_output.py
@@ -1,4 +1,5 @@
-from time import sleep
+import time
+from threading import Event
 from unittest import mock
 
 import pytest
@@ -14,35 +15,22 @@ def mock_sub(mock_sub):
 
 
 def test_output(mock_sub, popen_process, capsys):
-    """Readline output is printed."""
-    mock_sub.stream_output("testing", popen_process)
-
-    assert capsys.readouterr().out == ("output line 1\n" "\n" "output line 3\n")
-    mock_sub.cleanup.assert_called_once_with("testing", popen_process)
-
-
-def test_output_debug(mock_sub, popen_process, capsys):
-    """Readline output is printed; debug mode should not add extra output."""
-    mock_sub.command.logger = Log(verbosity=2)
-
+    """Process output is printed."""
     mock_sub.stream_output("testing", popen_process)
 
     # fmt: off
-    expected_output = (
+    assert capsys.readouterr().out == (
         "output line 1\n"
         "\n"
         "output line 3\n"
-        ">>> Return code: -3\n"
     )
     # fmt: on
-    assert capsys.readouterr().out == expected_output
-
     mock_sub.cleanup.assert_called_once_with("testing", popen_process)
 
 
-def test_output_deep_debug(mock_sub, popen_process, capsys):
-    """Readline output is printed with debug return code in deep debug mode."""
-    mock_sub.command.logger = Log(verbosity=3)
+def test_output_debug(mock_sub, popen_process, capsys):
+    """Process output is printed; no debug output for only stream_output."""
+    mock_sub.command.logger = Log(verbosity=2)
 
     mock_sub.stream_output("testing", popen_process)
 
@@ -51,7 +39,6 @@ def test_output_deep_debug(mock_sub, popen_process, capsys):
         "output line 1\n"
         "\n"
         "output line 3\n"
-        ">>> Return code: -3\n"
     )
     # fmt: on
     assert capsys.readouterr().out == expected_output
@@ -63,22 +50,17 @@ def test_keyboard_interrupt(mock_sub, popen_process, capsys):
     """KeyboardInterrupt is suppressed if user sends CTRL+C and all output is
     printed."""
 
-    def slow_poll(*a):
-        sleep(0.5)
-        return -3
-
-    # this helps ensure that the output streaming thread doesn't
-    # finish before is_alive() is called and therefore ensures
-    # that stop_func is always executed during this test.
-    popen_process.poll = mock.MagicMock()
-    popen_process.poll.side_effect = slow_poll
-
     send_ctrl_c = mock.MagicMock()
     send_ctrl_c.side_effect = [False, KeyboardInterrupt]
 
     mock_sub.stream_output("testing", popen_process, stop_func=send_ctrl_c)
 
-    assert capsys.readouterr().out == ("output line 1\n" "\n" "output line 3\n")
+    assert (
+        capsys.readouterr().out == "output line 1\n"
+        "\n"
+        "output line 3\n"
+        "Stopping...\n"
+    )
     mock_sub.cleanup.assert_called_once_with("testing", popen_process)
 
 
@@ -87,7 +69,13 @@ def test_process_exit_with_queued_output(mock_sub, popen_process, capsys):
     popen_process.poll.side_effect = [None, -3, -3, -3]
 
     mock_sub.stream_output("testing", popen_process)
-    assert capsys.readouterr().out == ("output line 1\n" "\n" "output line 3\n")
+    # fmt: off
+    assert capsys.readouterr().out == (
+        "output line 1\n"
+        "\n"
+        "output line 3\n"
+    )
+    # fmt: on
     mock_sub.cleanup.assert_called_once_with("testing", popen_process)
 
 
@@ -97,5 +85,52 @@ def test_stop_func(mock_sub, popen_process, stop_func_ret_val, capsys):
     mock_sub.stream_output(
         "testing", popen_process, stop_func=lambda: stop_func_ret_val
     )
-    assert capsys.readouterr().out == ("output line 1\n" "\n" "output line 3\n")
+    # fmt: off
+    assert capsys.readouterr().out == (
+        "output line 1\n"
+        "\n"
+        "output line 3\n"
+    )
+    # fmt: on
     mock_sub.cleanup.assert_called_once_with("testing", popen_process)
+
+
+def test_stuck_streamer(mock_sub, popen_process, monkeypatch, capsys):
+    """Following a KeyboardInterrupt, output streaming returns even if the
+    output streamer becomes stuck."""
+
+    # Mock time.time() to return times that monotonically increase by 1s
+    # every time it is invoked. This allows us to simulate the progress of
+    # time much faster than the actual calls to time.sleep() would.
+    mock_time = mock.MagicMock(side_effect=range(1000, 1005))
+    monkeypatch.setattr(time, "time", mock_time)
+
+    # Flag for the mock streamer to exit and prevent it
+    # potentially printing in the middle of a later test.
+    monkeypatched_streamer_should_exit = Event()
+
+    def monkeypatched_blocked_streamer(*a, **kw):
+        """Simulate a streamer that blocks longer than it will be waited on."""
+        time.sleep(1)
+        if monkeypatched_streamer_should_exit.is_set():
+            return
+        print("This should not be printed while waiting on the streamer to exit")
+
+    monkeypatch.setattr(
+        mock_sub,
+        "_stream_output_thread",
+        monkeypatched_blocked_streamer,
+    )
+
+    send_ctrl_c = mock.MagicMock()
+    send_ctrl_c.side_effect = [False, KeyboardInterrupt]
+    mock_sub.stream_output("testing", popen_process, stop_func=send_ctrl_c)
+
+    # fmt: off
+    assert capsys.readouterr().out == (
+        "Stopping...\n"
+        "Log stream hasn't terminated; log output may be corrupted.\n"
+    )
+    # fmt: on
+
+    monkeypatched_streamer_should_exit.set()