Repository: encode/uvicorn

Issue:
Subprocess returncode is not detected when running Gunicorn with Uvicorn (with fix PR companion)
### Checklist

<!-- Please make sure you check all these items before submitting your bug report. -->

- [x] The bug is reproducible against the latest release and/or `master`.
- [x] There are no similar issues or pull requests to fix it yet.

### Describe the bug

<!-- A clear and concise description of what the bug is. -->

When starting Gunicorn with Uvicorn worker(s), if the app uses `subprocess` to start other processes and captures the output, their `returncode` is in most cases `0`, even if the actual exit code was `1`.

### To reproduce

<!-- Provide a *minimal* example with steps to reproduce the bug locally.

NOTE: try to keep any external dependencies *at an absolute minimum* .
In other words, remove anything that doesn't make the bug go away.

-->

Take this minimal FastAPI app (or replace with Starlette), `main.py`:

```Python
import subprocess

from fastapi import FastAPI

app = FastAPI()

@app.post("/run")
def run_subprocess():
    result = subprocess.run(
        ["python", "-c", "import sys; sys.exit(1)"], capture_output=True
    )
    return {"returncode": result.returncode}
```

Then run it with:

```console
$ gunicorn -k uvicorn.workers.UvicornWorker main:app
```

Open the browser at http:127.0.0.1:8000/docs and send a request to `/run`.

### Expected behavior

<!-- A clear and concise description of what you expected to happen. -->

The detected `returncode` should always be `1`, as the subprocess always exits with `1`.

### Actual behavior

<!-- A clear and concise description of what actually happens. -->

In most of the cases it will return a `returncode` of `0`. Strangely enough, in some cases, it will return a `returncode` of `1`.

### Debugging material

<!-- Any tracebacks, screenshots, etc. that can help understanding the problem.

NOTE:
- Please list tracebacks in full (don't truncate them).
- If relevant, consider turning on DEBUG or TRACE logs for additional details (see the Logging section on https://www.uvicorn.org/settings/ specifically the `log-level` flag).
- Consider using `<details>` to make tracebacks/logs collapsible if they're very large (see https://gist.github.com/ericclemmons/b146fe5da72ca1f706b2ef72a20ac39d).
-->

This is because the `UvicornWorker`, which inherits from the base Gunicorn worker, declares a method `init_signals()` (overriding the parent method) but doesn't do anything. I suspect it's because the signal handlers are declared in the `Server.install_signal_handlers()` with compatibility with `asyncio`.

But the `UvicornWorker` process is started with `os.fork()` by Gunicorn (if I understand correctly) and by the point it is forked, the Gunicorn "Arbiter" class (that handles worker processes) already set its own signal handlers.

And the signal handlers in the Gunicorn base worker reset those handlers, but the `UvicornWorker` doesn't. So, when a process started with `subprocessing` is terminated, the `SIGCHLD` signal is handled by the Gunicorn `Arbiter` (as if the terminated process was a worker) instead of by the `UvicornWorker`.

Disclaimer: why the `SIGCHLD` signal handling in the Gunicorn `Arbiter` alters the `returncode` of a process run with `subprocess`, when capturing output, is still a mystery to me. But I realized the signal handler in the `Arbiter` is expected to handle dead worker processes. And worker subclasses all seem to reset the signal handlers to revert those signals set by the `Arbiter`.

I'm also submitting a PR to fix this: https://github.com/encode/uvicorn/pull/895. It's just 3 lines of code. But debugging it and finding it took me almost a week. :sweat_smile: 

### Environment

- OS / Python / Uvicorn version: just run `uvicorn --version`: `Running uvicorn 0.13.1 with CPython 3.8.5 on Linux` (it's actually installed from source, for debugging)
- Gunicorn version (also installed from source, for debugging): `gunicorn (version 20.0.4)`
- The exact command you're running uvicorn with, all flags you passed included. If you run it with gunicorn please do the same. If there is a reverse-proxy involved and you cannot reproduce without it please give the minimal config of it to reproduce.

```console
$ gunicorn -k uvicorn.workers.UvicornWorker main:app
```

### Additional context

<!-- Any additional information that can help understanding the problem.

Eg. linked issues, or a description of what you were trying to achieve. -->

I'm pretty sure this issue https://github.com/encode/uvicorn/issues/584 is related to the same problem.


Discussion:

Resolving patch:
diff --git a/uvicorn/workers.py b/uvicorn/workers.py
--- a/uvicorn/workers.py
+++ b/uvicorn/workers.py
@@ -1,5 +1,6 @@
 import asyncio
 import logging
+import signal
 
 from gunicorn.workers.base import Worker
 
@@ -62,7 +63,11 @@ def init_process(self):
         super(UvicornWorker, self).init_process()
 
     def init_signals(self):
-        pass
+        # Reset signals so Gunicorn doesn't swallow subprocess return codes
+        # other signals are set up by Server.install_signal_handlers()
+        # See: https://github.com/encode/uvicorn/issues/894
+        for s in self.SIGNALS:
+            signal.signal(s, signal.SIG_DFL)
 
     def run(self):
         self.config.app = self.wsgi


Test patch: