#3179·sanic

[Bug] Secondary apps lack built-in HTTP signals (NotFound error) when receiving requests in spawn / dev mode

Author: NaCodermerCreated Jul 29, 2026Updated Sep 6, 2026
Labelsbug

Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

When running multiple Sanic applications using app.prepare() and Sanic.serve(primary=app) under dev=True (or any configuration that forces the spawn multiprocessing context), secondary applications fail to register their built-in HTTP signals in the spawned worker process.

When an HTTP request hits the secondary app's port, the worker process crashes immediately with a sanic_routing.exceptions.NotFound error because the underlying signal router is completely empty.

To Reproduce

Here is a Minimal Reproducible Example (MRE). I've included a custom managed worker process (via app.manager.manage) that automatically sends an HTTP request to the secondary app a few seconds after startup to easily trigger the bug.

python
import time
from urllib import request
from sanic import Sanic
from sanic.response import text

app = Sanic("PrimaryApp")
app_secondary = Sanic("SecondaryApp")

@app.get("/")
async def primary_handler(req):
    return text("Primary OK")

@app_secondary.get("/")
async def secondary_handler(req):
    return text("Secondary OK")

# A custom background process to automatically trigger the bug
def custom_worker_process():
    print("--- Wait 3 seconds for servers to start...")
    time.sleep(3)
    print("--- Sending request to secondary app...")
    try:
        # This will INSTANTLY trigger the NotFound crash in the server process!
        response = request.urlopen("http://127.0.0.1:8001/")
        html_content = response.read().decode('utf-8')
        print(f"---  Request SUCCESS! Response content: '{html_content}'")
    except Exception as e:
        print(f"--- ❌ Request failed due to server crash: {e}")

@app.main_process_ready
async def attach_custom_worker(app_: Sanic, _):
    # Spawn the custom process
    app_.manager.manage("trigger-bot", custom_worker_process, {}, workers=1)

if __name__ == "__main__":
    app.prepare(port=8000, dev=True)
    app_secondary.prepare(port=8001, dev=True)
    
    Sanic.serve(primary=app)

Steps:

  1. Run the script above.
  2. Wait 3 seconds for the trigger-bot process to send the request to port 8001.

Traceback / Output: Instead of a successful response, the background worker reports a 500 error:

--- Wait 3 seconds for servers to start...
--- Sending request to secondary app...
--- ❌ Request failed due to server crash: HTTP Error 500: Internal Server Error

And the Sanic server console raises the following exception:

sanic_routing.exceptions.NotFound: Could not find signal http.lifecycle.begin
...
sanic_routing.exceptions.NotFound: Could not find signal http.lifecycle.complete

Root Cause Analysis

I dug into the source code (sanic/mixins/startup.py and sanic/worker/serve.py) and found the missing link during the worker spawn lifecycle:

  1. In worker_serve, only the primary app is passed to _serve_http_1(), which eventually triggers app._setup_server() and app.register_builtins(). Thus, the primary app gets its full signal tree.
  2. Secondary apps are launched via the app._start_servers hook attached to before_server_start.
  3. Inside _run_server (which is called by _start_servers for secondary apps), the framework executes await server_info.server.startup(), but it never calls register_builtins() or _setup_server() for these secondary apps. As a result, the signal_router for non-primary apps remains completely empty in the spawned process.

Current Workaround

To bypass this issue without patching the framework itself, I currently have to inject dummy handlers into the secondary app in the global scope before __main__. This forces the worker process to compile the required signal tree:

python
def patch_sanic_secondary_app(app: Sanic) -> Sanic:
    """
    Workaround: Pre-register dummy handlers for core HTTP signals
    to prevent NotFound crashes in spawn mode.
    """
    async def _dummy_signal_handler(*args, **kwargs):
        pass

    core_events = [
        "http.lifecycle.begin", "http.lifecycle.read_head", "http.lifecycle.request",
        "http.lifecycle.handle", "http.lifecycle.read_body", "http.lifecycle.response",
        "http.lifecycle.complete", "http.lifecycle.exception", 
        "http.routing.before", "http.routing.after",
        "http.handler.before", "http.handler.after"
    ]
    
    for event in core_events:
        try:
            app.add_signal(_dummy_signal_handler, event)
        except Exception:
            pass

    for attach in ["request", "response"]:
        try:
            app.add_signal(_dummy_signal_handler, "http.middleware.before", condition={"attach_to": attach})
            app.add_signal(_dummy_signal_handler, "http.middleware.after", condition={"attach_to": attach})
        except Exception:
            pass

    return app

# Usage:
# app_secondary = patch_sanic_secondary_app(Sanic("SecondaryApp"))

Expected behavior

Sanic's worker manager should ensure that register_builtins() (or the equivalent initialization) is automatically executed for all prepared apps assigned to a worker, not just the primary app, so that secondary apps can handle HTTP requests properly in spawn mode.

Environment

  • OS: Linux / Windows (Any OS using spawn multiprocessing context)
  • Sanic Version: 23.x / 24.x / 25.x (Tested on 25.12.1)


### Code snippet

_No response_

### Expected Behavior

_No response_

### How do you run Sanic?

Sanic CLI

### Operating System

Linux

### Sanic Version

25.12.1

### Additional context

_No response_