Repository: sanic-org/sanic

Issue:
request middleware not running when registered in a method since 22.9.0
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Describe the bug

Prior to 22.9.0, you could register request middleware in functions and listeners. Since 22.9.0 this has stopped working. I'm unclear on whether this is by design or not, but I wasn't able to find anything in the docs that explains the discrepancy.

The last working version I tested was 22.6.2.

### Code snippet

```
from sanic import Sanic, response                                                                                                                                                                                                              
                                                                                                                                                                                                                                               
app = Sanic("my-hello-world-app")                                                                                                                                                                                                              
                                                                                                                                                                                                                                               
@app.middleware("request")                                                                                                                                                                                                                     
async def this_always_works(request):                                                                                                                                                                                                          
    print("this is request middleware")                                                                                                                                                                                                        
                                                                                                                                                                                                                                               
@app.before_server_start                                                                                                                                                                                                                       
async def before_server_start_listener(app, loop):                                                                                                                                                                                             
    def this_fails_in_22_9_0_and_after(request):                                                                                                                                                                                               
        print("this will not fire after in 22.9.0 and after")                                                                                                                                                                                  
    app.register_middleware(this_fails_in_22_9_0_and_after, "request")                                                                                                                                                                         
                                                                                                                                                                                                                                               
@app.route('/')                                                                                                                                                                                                                                
async def test(request):                                                                                                                                                                                                                       
    return response.json({'hello': 'world'})                                                                                                                                                                                                   
                                                                                                                                                                                                                                               
if __name__ == '__main__':                                                                                                                                                                                                                     
    app.register_middleware(lambda app: print("this will also not fire in 22.9.0 and after"), "request")                                                                                                                                       
    app.run(host="0.0.0.0", debug=True, auto_reload=True)                                                                                                                                                                                      
```

### Expected Behavior

I expected all middleware to fire, but only the first one I set up fires since 22.9.0. I know middlware priority was introduced in 22.9, but I wouldn't expect that to have broken existing apps. I tried explicitly setting the priority and I still was not able to get the middleware to fire.

### How do you run Sanic?

As a script (`app.run` or `Sanic.serve`)

### Operating System

Linux

### Sanic Version

22.9.0

### Additional context

Thanks for Sanic!


Discussion:
When I first looked at this earlier I was super shocked and quite disturbed to hear this.

1. This is meant to be accomplished
2. I do this very thing in a bunch of production applications

So I think we need to clarify a few things.

## Middleware added in a listener _will_ work and run

Your example code draws an incorrect conclusion here:

```python
@app.before_server_start
async def before_server_start_listener(app, loop):
    def this_fails_in_22_9_0_and_after(request):
        print("this will not fire after in 22.9.0 and after")
    app.register_middleware(this_fails_in_22_9_0_and_after, "request")
```

This _should_ work and will work. Or, rather, I cannot reproduce a scenario where it does not.

![image](https://user-images.githubusercontent.com/166269/222107043-851ecd5c-8de8-4fb9-bd08-941ee12a8277.png)


## Adding to an app instance inside an `if __name__ == "__main__"` block is _NOT_ supported

When you define this block...

```py
if __name__ == '__main__':
    app.register_middleware(lambda app: print("this will also not fire in 22.9.0 and after"), "request")
    app.run(host="0.0.0.0", debug=True, auto_reload=True)
```

... what it means is **ONLY** run this code when the main Python interpreter process is running.

Sanic underwent a big ([yet super powerful)](https://sanic.dev/en/guide/deployment/manager.html) [shift in 22.9](https://sanic.dev/en/guide/release-notes/v22.9.html#warning-important-new-worker-manager-rocket). Every worker gets its own dedicated process. This enables scaling, coordination, etc, etc.

But it also means that anything run in that block is not attached to your actual running Application instance.

So, what can you do?

### Use a factory, or don't attach dynamically at run time in the `if __name__ == "__main__"` block

Going forward we are going to change a lot of the documents to really suggest that developers lean more heavily on the CLI. When you do, using the factory pattern becomes super easy and easier for such runtime changes.

```py
def create_app():
    app = Sanic("MyApp")
    ... # do stuff
    return app
```
```
$ sanic path.to.server:create_app --factory
```

You can achieve this of course with `if __name__ == "__main__"` and `app.run`, and that will of course always be supported. And, sometimes the simple pattern I showed above is inadequate.

Therefore, we documented how to create [dynamic runtime applications](https://sanic.dev/en/guide/deployment/app-loader.html). Perhaps this method will work for you?

### Use `single_process` mode

If you never intend to use multiple workers, perhaps you only want to run Sanic in single process mode:

```python
if __name__ == "__main__":
    app.register_middleware(
        lambda app: print("this will also not fire in 22.9.0 and after"),
        "request",
    )
    app.run(..., single_process=True)
```

This does have a disadvantage though because now you do not have auto-reload for local development. That is itself running in a background process, therefore it is a tradeoff you need to decide upon.

### Keep runtime global scope

One thing is to simply move your registration into the global scope.

```python
app.register_middleware(lambda app: print("this will also not fire in 22.9.0 and after"), "request")

if __name__ == '__main__':
    app.run(host="0.0.0.0", debug=True, auto_reload=True)
```

Now, that will trigger on both the main process and every single worker process to attach your middleware.

### Run _ONLY_ in the correct process

Since we ultimately want to run the `register_middleware` on the worker process, you could just be explicit about it:

```python
if __name__ == "__mp_main__":
    app.register_middleware(
        lambda app: print("this will also not fire in 22.9.0 and after"),
        "request",
    )
elif __name__ == "__main__":
    app.run(host="0.0.0.0", debug=True, auto_reload=True)
```

There really is no reason to have the registration of middleware in the main app in most use cases. But, you could of course see how you could do both if you needed.

---

One final note:

This...

```py
app.run(host="0.0.0.0", debug=True, auto_reload=True)
```

can be simplified...

```py
app.run(host="0.0.0.0", dev=True)
```
Thanks for the quick and thoughtful response!

It's really the other issue, attaching middleware in `before_server_start`, that's blocking us from upgrading. I noticed the `main` issue when I was experimenting and trying to resolve that issue in various ways. Since it was a similar behavior change between the versions, I assumed it was probably part of the same issue which is why I included it in the report.

I'm using Python 3.10 and running Sanic via poetry in Fedora Linux, but I originally noticed this problem in Docker on my Mac laptop. I haven't had a chance to try the minimal example below on my Mac today, but I will give it a try tomorrow and report back here.

Can you see anything in my setup that would make this reproduce consistently for me? More details are below.

My `pyproject.toml` file looks like this:

```toml
[tool.poetry]                                                                                                                                                                                                                                  
name = "sanic-bug"                                                                                                                                                                                                                             
version = "0.1.0"                                                                                                                                                                                                                              
description = ""                                                                                                                                                                                                                               
authors = ["Semmy Purewal <redacted@redacted.com>"]                                                                                                                                                                                           
readme = "README.md"                                                                                                                                                                                                                           
                                                                                                                                                                                                                                               
[tool.poetry.dependencies]                                                                                                                                                                                                                     
python = "^3.10"                                                                                                                                                                                                                               
sanic = "22.6.2"                                                                                                                                                                                                                               
                                                                                                                                                                                                                                               
                                                                                                                                                                                                                                               
[build-system]                                                                                                                                                                                                                                 
requires = ["poetry-core"]                                                                                                                                                                                                                     
build-backend = "poetry.core.masonry.api" 
```

And my `server.py` looks like this:

```py
from sanic import Sanic, response                                                                                                                                                                                                              
                                                                                                                                                                                                                                               
app = Sanic("my-hello-world-app")                                                                                                                                                                                                              
                                                                                                                                                                                                                                               
@app.middleware("request")                                                                                                                                                                                                                     
async def this_always_works(request):                                                                                                                                                                                                          
    print("this is request middleware")                                                                                                                                                                                                        
                                                                                                                                                                                                                                               
@app.before_server_start                                                                                                                                                                                                                       
async def before_server_start_listener(app, loop):                                                                                                                                                                                             
    def this_fails_in_22_9_0_and_after(request):                                                                                                                                                                                               
        print("this will not fire after in 22.9.0 and after")                                                                                                                                                                                  
    app.register_middleware(this_fails_in_22_9_0_and_after, "request")                                                                                                                                                                         
                                                                                                                                                                                                                                               
@app.route('/')                                                                                                                                                                                                                                
async def test(request):                                                                                                                                                                                                                       
    return response.json({'hello': 'world'})                                                                                                                                                                                                   
                                                                                                                                                                                                                                               
if __name__ == '__main__':                                                                                                                                                                                                                     
    app.run(host="0.0.0.0", dev=True)           
```

Running `poetry install`, then `poetry run python server.py`, then hitting the endpoint, both middlewares run as we'd expect:

```
$ poetry run python server.py
[2023-03-01 20:13:34 -0500] [1401809] [INFO] 
  ┌──────────────────────────────────────────────────────────────────────────────────────┐
  │                                    Sanic v22.6.2                                     │
  │                           Goin' Fast @ http://0.0.0.0:8000                           │
  ├───────────────────────┬──────────────────────────────────────────────────────────────┤
  │                       │        mode: debug, single worker                            │
  │     ▄███ █████ ██     │      server: sanic, HTTP/1.1                                 │
  │    ██                 │      python: 3.10.9                                          │
  │     ▀███████ ███▄     │    platform: Linux-6.1.7-100.fc36.x86_64-x86_64-with-glibc2. │
  │                 ██    │              35                                              │
  │    ████ ████████▀     │ auto-reload: enabled                                         │
  │                       │    packages: sanic-routing==22.3.0                           │
  │ Build Fast. Run Fast. │                                                              │
  └───────────────────────┴──────────────────────────────────────────────────────────────┘

[2023-03-01 20:13:34 -0500] [1401809] [DEBUG] Dispatching signal: server.init.before
[2023-03-01 20:13:34 -0500] [1401809] [DEBUG] Dispatching signal: server.init.after
[2023-03-01 20:13:34 -0500] [1401809] [INFO] Starting worker [1401809]
[2023-03-01 20:13:38 -0500] [1401809] [DEBUG] Dispatching signal: http.lifecycle.begin
[2023-03-01 20:13:38 -0500] [1401809] [DEBUG] Dispatching signal: http.lifecycle.read_head
[2023-03-01 20:13:38 -0500] [1401809] [DEBUG] Dispatching signal: http.lifecycle.request
[2023-03-01 20:13:38 -0500] [1401809] [DEBUG] Dispatching signal: http.lifecycle.handle
[2023-03-01 20:13:38 -0500] [1401809] [DEBUG] Dispatching signal: http.routing.before
[2023-03-01 20:13:38 -0500] [1401809] [DEBUG] Dispatching signal: http.routing.after
[2023-03-01 20:13:38 -0500] [1401809] [DEBUG] Dispatching signal: http.middleware.before
this is request middleware
[2023-03-01 20:13:38 -0500] [1401809] [DEBUG] Dispatching signal: http.middleware.after
[2023-03-01 20:13:38 -0500] [1401809] [DEBUG] Dispatching signal: http.middleware.before
this will not fire after in 22.9.0 and after
[2023-03-01 20:13:38 -0500] [1401809] [DEBUG] Dispatching signal: http.middleware.after
[2023-03-01 20:13:38 -0500] [1401809] [DEBUG] Dispatching signal: http.lifecycle.response
[2023-03-01 20:13:38 -0500] - (sanic.access)[INFO][127.0.0.1:39236]: GET http://localhost:8000/  200 17
[2023-03-01 20:13:38 -0500] [1401809] [DEBUG] Dispatching signal: http.lifecycle.send
[2023-03-01 20:13:38 -0500] [1401809] [DEBUG] Dispatching signal: http.lifecycle.begin
[2023-03-01 20:13:43 -0500] [1401809] [DEBUG] KeepAlive Timeout. Closing connection.
```

Updating `pyproject.toml` to use Sanic 22.9.0, reinstalling dependencies and running again exactly as above shows the middleware no longer runs for us. I also tried removing and recreating the venv, but still had the same results.

```
$ poetry run python server.py
[2023-03-01 20:16:31 -0500] [1411105] [INFO] 
  ┌──────────────────────────────────────────────────────────────────────────────────────┐
  │                                    Sanic v22.9.0                                     │
  │                           Goin' Fast @ http://0.0.0.0:8000                           │
  ├───────────────────────┬──────────────────────────────────────────────────────────────┤
  │                       │        mode: debug, single worker                            │
  │     ▄███ █████ ██     │      server: sanic, HTTP/1.1                                 │
  │    ██                 │      python: 3.10.9                                          │
  │     ▀███████ ███▄     │    platform: Linux-6.1.7-100.fc36.x86_64-x86_64-with-glibc2. │
  │                 ██    │              35                                              │
  │    ████ ████████▀     │ auto-reload: enabled                                         │
  │                       │    packages: sanic-routing==22.8.0                           │
  │ Build Fast. Run Fast. │                                                              │
  └───────────────────────┴──────────────────────────────────────────────────────────────┘

[2023-03-01 20:16:31 -0500] [1411105] [DEBUG] Starting a process: Sanic-Server-0-0
[2023-03-01 20:16:31 -0500] [1411105] [DEBUG] Starting a process: Sanic-Reloader-0
[2023-03-01 20:16:31 -0500] [1411116] [INFO] Starting worker [1411116]
this is request middleware
[2023-03-01 20:16:36 -0500] - (sanic.access)[INFO][127.0.0.1:46656]: GET http://localhost:8000/  200 17
[2023-03-01 20:16:41 -0500] [1411116] [DEBUG] KeepAlive Timeout. Closing connection.
[2023-03-01 20:16:41 -0500] [1411116] [DEBUG] KeepAlive Timeout. Closing connection.
```

If you don't see anything obvious, we'll start digging into the code.
I am not experiencing that
![image](https://user-images.githubusercontent.com/166269/222399651-d1d48a1f-3134-4ea9-9b8c-69d1f4d59c98.png)

Oh.... I see. :thinking: The difference was I had `sanic-ext` in my environment. Going to a clean env without that I see what you are seeing.

So the reason is that you are altering the router after it has tried to optimize itself. I will add something to the documentation about this.

Add this:

```py
    app.router.reset()
    app.register_middleware(this_fails_in_22_9_0_and_after, "request")
    app.finalize()
```

Resolving patch:
diff --git a/sanic/app.py b/sanic/app.py
--- a/sanic/app.py
+++ b/sanic/app.py
@@ -16,7 +16,7 @@
 )
 from asyncio.futures import Future
 from collections import defaultdict, deque
-from contextlib import suppress
+from contextlib import contextmanager, suppress
 from functools import partial
 from inspect import isawaitable
 from os import environ
@@ -33,6 +33,7 @@
     Deque,
     Dict,
     Iterable,
+    Iterator,
     List,
     Optional,
     Set,
@@ -433,14 +434,15 @@ def _apply_route(self, route: FutureRoute) -> List[Route]:
 
         ctx = params.pop("route_context")
 
-        routes = self.router.add(**params)
-        if isinstance(routes, Route):
-            routes = [routes]
+        with self.amend():
+            routes = self.router.add(**params)
+            if isinstance(routes, Route):
+                routes = [routes]
 
-        for r in routes:
-            r.extra.websocket = websocket
-            r.extra.static = params.get("static", False)
-            r.ctx.__dict__.update(ctx)
+            for r in routes:
+                r.extra.websocket = websocket
+                r.extra.static = params.get("static", False)
+                r.ctx.__dict__.update(ctx)
 
         return routes
 
@@ -449,17 +451,19 @@ def _apply_middleware(
         middleware: FutureMiddleware,
         route_names: Optional[List[str]] = None,
     ):
-        if route_names:
-            return self.register_named_middleware(
-                middleware.middleware, route_names, middleware.attach_to
-            )
-        else:
-            return self.register_middleware(
-                middleware.middleware, middleware.attach_to
-            )
+        with self.amend():
+            if route_names:
+                return self.register_named_middleware(
+                    middleware.middleware, route_names, middleware.attach_to
+                )
+            else:
+                return self.register_middleware(
+                    middleware.middleware, middleware.attach_to
+                )
 
     def _apply_signal(self, signal: FutureSignal) -> Signal:
-        return self.signal_router.add(*signal)
+        with self.amend():
+            return self.signal_router.add(*signal)
 
     def dispatch(
         self,
@@ -1520,6 +1524,27 @@ def _check_uvloop_conflict(cls) -> None:
     # Lifecycle
     # -------------------------------------------------------------------- #
 
+    @contextmanager
+    def amend(self) -> Iterator[None]:
+        """
+        If the application has started, this function allows changes
+        to be made to add routes, middleware, and signals.
+        """
+        if not self.state.is_started:
+            yield
+        else:
+            do_router = self.router.finalized
+            do_signal_router = self.signal_router.finalized
+            if do_router:
+                self.router.reset()
+            if do_signal_router:
+                self.signal_router.reset()
+            yield
+            if do_signal_router:
+                self.signalize(self.config.TOUCHUP)
+            if do_router:
+                self.finalize()
+
     def finalize(self):
         try:
             self.router.finalize()


Test patch:
diff --git a/tests/test_late_adds.py b/tests/test_late_adds.py
new file mode 100644
--- /dev/null
+++ b/tests/test_late_adds.py
@@ -0,0 +1,54 @@
+import pytest
+
+from sanic import Sanic, text
+
+
+@pytest.fixture
+def late_app(app: Sanic):
+    app.config.TOUCHUP = False
+    app.get("/")(lambda _: text(""))
+    return app
+
+
+def test_late_route(late_app: Sanic):
+    @late_app.before_server_start
+    async def late(app: Sanic):
+        @app.get("/late")
+        def handler(_):
+            return text("late")
+
+    _, response = late_app.test_client.get("/late")
+    assert response.status_code == 200
+    assert response.text == "late"
+
+
+def test_late_middleware(late_app: Sanic):
+    @late_app.get("/late")
+    def handler(request):
+        return text(request.ctx.late)
+
+    @late_app.before_server_start
+    async def late(app: Sanic):
+        @app.on_request
+        def handler(request):
+            request.ctx.late = "late"
+
+    _, response = late_app.test_client.get("/late")
+    assert response.status_code == 200
+    assert response.text == "late"
+
+
+def test_late_signal(late_app: Sanic):
+    @late_app.get("/late")
+    def handler(request):
+        return text(request.ctx.late)
+
+    @late_app.before_server_start
+    async def late(app: Sanic):
+        @app.signal("http.lifecycle.request")
+        def handler(request):
+            request.ctx.late = "late"
+
+    _, response = late_app.test_client.get("/late")
+    assert response.status_code == 200
+    assert response.text == "late"