--mount: several applications in one process (stage 1) - #95
Merged
github-actions[bot] merged 6 commits intoAug 24, 2026
Conversation
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
marked this pull request as ready for review
August 24, 2026 23:09
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
uvicornorgunicornonce 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 8099Longest-prefix routing on segment boundaries (
/appnever 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__alreadyexecs 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:
SCRIPT_NAME = prefixwithPATH_INFOtrimmed to the remainder.root_path = prefixwithpathleft whole — Django'sASGIHandlerstrips the prefix itself and handsrequest.paththe untrimmed value.PyBridge.set_baseis therefore the one place either protocol learns it, and the shim's_scope_from_environnow rebuilds the full path asSCRIPT_NAME + PATH_INFOrather than reading the trimmedPATH_INFOalone.WSGIHandler.buildis likewise the one place applications are constructed — for m0serve's own path and for--threads/--blocking-threadshandlers 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),
--mountwith--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_mixis two frameworks on purpose: two Django projects would sharedjango.conf.settingsand the first import would win, which would make the isolation claim a lie.poe smoke-hybridasserts both mounts answer; the banner names them; Django'sreverse(), Flask'surl_for(),base_urland bothrequest.pathvalues byte for byte; the Mojo-side 404 with its own body;/shoppingnot swallowed by/shop; the exact mount point answering werkzeug's 308 (which it can only build fromSCRIPT_NAME); and all four refusals.Verified load-bearing: with the
PATH_INFOtrim disabled, the Flask mount stops routing at all and the smoke fails on the first assertion.Also: 19 new
test_clitests (79 total), warning ratchet at its 68-site baseline,test-allgreen, 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