Skip to content

--mount: several applications in one process (stage 1) - #95

Merged
github-actions[bot] merged 6 commits into
mainfrom
claude/hybrid-asgi-wsgi-gateway-ex1ryv
Aug 24, 2026
Merged

--mount: several applications in one process (stage 1)#95
github-actions[bot] merged 6 commits into
mainfrom
claude/hybrid-asgi-wsgi-gateway-ex1ryv

Conversation

@codetalcott

Copy link
Copy Markdown
Owner

Why

v0.9.0's gateway answers "which protocol is this app?" — but the premise that started the work was messier: a codebase that is partly sync and partly async, where the developer shouldn't have to choose. Detection alone doesn't settle that; it just tells you which single server to run. And detection is a convenience — anyone can run uvicorn or gunicorn once they know which they have.

What no other server in this space can do is host a sync app and an async app in one process, each in its own native execution mode, routed in Mojo before either sees the request. uvicorn hosts one callable; daphne hosts one; Granian hosts one. Mixing today means two processes behind a proxy, or composing in Python (Starlette Mount + WSGIMiddleware, which drops the sync app onto the event loop's threadpool and inherits every limit that implies).

This PR is stage 1: the routing and the prefix semantics. Stage 2 is the payoff — each mount in its own native mode — and is deliberately separate, because stage 1 is where the semantic risk lives (URL prefixes) and stage 2 is where the concurrency risk lives. Landing them together would make a bisect useless.

What works

bin/m0serve --mount /=shop.wsgi --mount /portal=portal.wsgi:app \
    --app-dir apps/hybrid_mix --port 8099

Longest-prefix routing on segment boundaries (/app never swallows /application; the root mount is the empty prefix and needs no special case — every target starts with /, so it matches at length 0 and any deeper mount outranks it). Each mount detects its own protocol, discovery included. A path no mount claims is a 404 answered in Mojo, never entering Python.

Four pieces of existing machinery made this an extension rather than a rewrite — most importantly, PyBridge.__init__ already execs the shim into a fresh namespace dict per instance, so N apps are N isolated shim states (_app, _loop, _lifespan_state) with no shim redesign at all.

The load-bearing part: the prefix

The two protocols disagree, and getting it backwards leaves every direct request working while every generated URL breaks — invisible until someone clicks something:

  • WSGI wants SCRIPT_NAME = prefix with PATH_INFO trimmed to the remainder.
  • ASGI wants root_path = prefix with path left whole — Django's ASGIHandler strips the prefix itself and hands request.path the untrimmed value.

PyBridge.set_base is therefore the one place either protocol learns it, and the shim's _scope_from_environ now rebuilds the full path as SCRIPT_NAME + PATH_INFO rather than reading the trimmed PATH_INFO alone. WSGIHandler.build is likewise the one place applications are constructed — for m0serve's own path and for --threads/--blocking-threads handlers alike — so the mounted and unmounted shapes cannot drift.

Refused rather than guessed

Each with a message saying why: mixed WSGI/ASGI mounts (routing them is done; running an ASGI app through the buffered bridge beside a WSGI one would quietly cost it the streaming Phase 3 just gave it), --mount with --realtime (an inbound WebSocket message is delivered back into one application's urlconf — which mount has no defensible answer), and a mounted server taking the asyncio executor (it's one loop owning one app's bridge; N ASGI mounts would be N executor threads fed by a submit channel that cannot say which of them a job is for — per-mount submit channels are stage 2).

Verification

apps/hybrid_mix is two frameworks on purpose: two Django projects would share django.conf.settings and the first import would win, which would make the isolation claim a lie.

poe smoke-hybrid asserts both mounts answer; the banner names them; Django's reverse(), Flask's url_for(), base_url and both request.path values byte for byte; the Mojo-side 404 with its own body; /shopping not swallowed by /shop; the exact mount point answering werkzeug's 308 (which it can only build from SCRIPT_NAME); and all four refusals.

Verified load-bearing: with the PATH_INFO trim disabled, the Flask mount stops routing at all and the smoke fails on the first assertion.

Also: 19 new test_cli tests (79 total), warning ratchet at its 68-site baseline, test-all green, and the full smoke sweep.

Three stale claims corrected

Written before Phase 3 shipped and now wrong in three places: the README told users infinite SSE streams "are still refused with an explanatory error until the streaming surface lands", and the CI and pyproject comments said the FastHTML row's EventStream was pinned to that refusal. They stream.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Peb7fsujBzqVzKkTQzGytY


Generated by Claude Code

claude added 6 commits August 24, 2026 22:32
The pure half of hosting several applications in one process: a repeatable
--mount PREFIX=SPEC, and match_mount, the longest-prefix router they will
be dispatched through. No behaviour is wired yet -- m0serve still serves
the positional spec -- so this is parsing plus a pure function plus their
tests.

Two decisions worth naming, both pinned by tests:

- A prefix is stored WITHOUT its trailing slash, so the root mount '/' is
  the empty string. That is exactly PEP 3333's SCRIPT_NAME and exactly
  ASGI's root_path for an application at the root, so the two protocols
  and the matcher all see one shape instead of three.
- match_mount matches only on segment boundaries, so /app serves /app and
  /app/x but never /application. The empty root prefix needs no special
  case: every request target starts with '/', so it matches at length 0
  and any deeper mount outranks it -- which is what lets a Django project
  at / coexist with a FastHTML app at /app instead of shadowing it.

--mount and a positional spec are exclusive, a duplicate prefix is
refused (after normalisation, so '/app' and '/app/' collide), and a
prefix that does not start with '/' is a usage error. Discovery applies
to a mount exactly as to a positional spec: a bare MODULE still tries
MODULE.asgi, MODULE.wsgi and the rest, and an explicit :ATTR never falls
back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Peb7fsujBzqVzKkTQzGytY
WSGIHandler holds a list of applications rather than one, and routes by
longest prefix through cli.mojo's match_mount. A server with no --mount
holds a single app at the empty prefix, so the ordinary case is the
degenerate mount and there is one dispatch path rather than two. A path
no mount claims is a 404 answered in Mojo, never entering Python.

The prefix reaches both protocols through one seam, PyBridge.set_base,
because they disagree about what it means and getting that backwards
silently corrupts every URL an app generates:

- WSGI wants SCRIPT_NAME=prefix with PATH_INFO trimmed to the remainder,
  so build_environ slices the path bytes by the prefix length. PEP 3333
  says PATH_INFO is empty (not "/") when the target names the mount point
  exactly, which Django answers with its APPEND_SLASH redirect exactly as
  it does under gunicorn.
- ASGI wants root_path=prefix with `path` left WHOLE -- Django's
  ASGIHandler strips the prefix itself and hands request.path the
  untrimmed value -- so the shim's _scope_from_environ now rebuilds the
  full path as SCRIPT_NAME + PATH_INFO instead of reading the trimmed
  PATH_INFO alone, and both the http and websocket scopes carry
  root_path.

WSGIApp gains a script_name argument that feeds set_base; the executor's
handler.app references become apps[0], which is exact -- an executor
thread has one loop and therefore one app.

OwningList[WSGIApp] is built with capacity=1 rather than the no-argument
form, which constructs a null Pointer that Mojo 1.0 refuses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Peb7fsujBzqVzKkTQzGytY
Wires --mount through both serve paths. Each mount resolves its protocol
independently -- discovery included, so --mount /=djangoproj finds
djangoproj.wsgi exactly as a positional spec would -- and the winner is
written back so the banner and every handler name what is actually
served.

WSGIHandler.build is now the one place applications are constructed, for
m0serve's own path and for --threads and --blocking-threads handlers
alike, so the mounted and unmounted shapes cannot drift: without --mount
it builds the single positional app at the empty prefix, and with it, one
app per mount carrying its prefix as script_name.

Two refusals, both explained rather than implied:

- Mixed WSGI and ASGI mounts. Routing them is done; giving each its
  native execution mode is the next stage, and running an ASGI app
  through the buffered bridge beside a WSGI one would quietly cost it
  its streaming.
- --mount with --realtime. M0-Hold subscribes a connection to registries
  the loop's handler owns, and an inbound WebSocket message is delivered
  back into ONE application's urlconf; which mount should receive it has
  no defensible answer.

A mounted server also stays off the asyncio executor for now: the
executor is one loop owning one application's bridge, and N ASGI mounts
would be N executor threads fed by a submit channel that cannot say
which of them a job is for. Per-mount submit channels are stage 2.

Verified end to end: two Django bridges in one process, / and /second,
each answering its own view with PATH_INFO trimmed to the remainder,
and an unmounted path answered 404 in Mojo without entering Python.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Peb7fsujBzqVzKkTQzGytY
apps/hybrid_mix serves a Django project and a Flask app from one m0serve
process at different prefixes. Two FRAMEWORKS on purpose: two Django
projects would share django.conf.settings and the first import would win,
which would make the isolation claim a lie, where two frameworks cannot
accidentally share anything.

The load-bearing assertions are the generated URLs. Both frameworks build
absolute URLs from SCRIPT_NAME, so a server that hands an application a
prefix it does not actually strip from PATH_INFO breaks every link while
every direct request still works -- invisible until someone clicks
something. smoke-hybrid compares Django's reverse(), Flask's url_for()
and both frameworks' request.path byte for byte, and it is verified
load-bearing: with the PATH_INFO trim disabled the Flask mount stops
routing at all and the smoke fails.

The rest of the phases: a root mount beside a prefixed one; a 404 for an
unmounted path answered in Mojo, with its own body, never entering
Python; /shopping not swallowed by the /shop mount; the exact mount point
answering werkzeug's 308 to the trailing slash, which it can only build
from SCRIPT_NAME; and all four refusals (mixed protocols, --realtime,
--mount beside a positional spec, a duplicate prefix).

live/asgi.py is the module the mixed-mount refusal is proven against, and
is what stage 2 will mount for real.

Also corrects two stale comments that Phase 3a made wrong: the FastHTML
row's EventStream is no longer "pinned to the watchdog's refusal until
the streaming surface lands" -- it streams.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Peb7fsujBzqVzKkTQzGytY
CLAUDE.md and docs/ROADMAP.md gain the mounted-applications design; the
README gains the --mount example and the prefix contract.

The corrections are the same staleness in three places, all written
before Phase 3 shipped and all now wrong: the README told users infinite
SSE streams "are still refused with an explanatory error until the
streaming surface lands", and the CI and pyproject comments said the
FastHTML row's EventStream was pinned to that refusal. They stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Peb7fsujBzqVzKkTQzGytY
The smokes write their server logs and probe output into the repo root
and delete them on success, so they only surface as untracked files when
a run fails or is inspected mid-flight -- which is exactly when someone
is most likely to commit one by accident. .gitignore already enumerated
about half of them; this adds the rest, including hybrid.log and
hybrid2.log from the new mounted-applications row.

The per-connection probes are globs rather than names because their index
is a shell loop variable (mw_sse_1.txt, mw_sse_2.txt, ...). The genuinely
ambiguous singletons are deliberately left out: a bare headers.txt or
http.txt is a name a real file could want one day, and hiding one of
those is worse than listing an artifact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Peb7fsujBzqVzKkTQzGytY
@codetalcott
codetalcott marked this pull request as ready for review August 24, 2026 23:09
@codetalcott codetalcott added the automerge Merge automatically once the Tests workflow passes label Aug 24, 2026 — with Claude
@github-actions
github-actions Bot merged commit 92cb47b into main Aug 24, 2026
4 checks passed
@github-actions
github-actions Bot deleted the claude/hybrid-asgi-wsgi-gateway-ex1ryv branch August 24, 2026 23:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automerge Merge automatically once the Tests workflow passes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants