genropy-asgi for Dummies

Serve legacy GenroPy sites on ASGI — no daemon, and scale across processes on demand

The Two Walls a GenroPy Site Hits

You have a GenroPy site. It runs. But two things stand between you and a modern deployment.

Wall one: one synchronous process. A GenroPy site is synchronous WSGI. Under gnrwsgiserve it serves requests through a single process and its thread pool. A handful of concurrent users and it saturates — there is no async, no WebSocket, no way to spread the load without standing up an external load balancer and sharing session state by hand.

Wall two: the register daemon. Every request touches the site register — who is connected, which pages exist, who is logged in, what datachanges to push. Historically that register was a separate process on a wire (Pyro4, then the genro-nodaemon TCP daemon): one more thing to launch, to keep alive, to debug when it hangs.

genropy-asgi removes both walls with one command. gnrasgiserve runs your unmodified site on uvicorn (ASGI). Add --workers N and the same command spreads it over a supervised pool of processes, each user pinned to a stable worker. And there is no daemon — the register is served in-process.

Browsers (uvicorn / ASGI) COMMANDER sticky routing by sticky_cid cookie WORKER 1 GnrWsgiSite in-process register WORKER 2 GnrWsgiSite in-process register WORKER 3 GnrWsgiSite in-process register no daemon

What you actually get

Drop-in for gnrwsgiserve

Same site name, same options, unmodified code. gnrasgiserve mysite and you are on uvicorn.

Scale across processes

--workers N turns one process into a commander plus a pool that grows under load. No external balancer.

No register daemon

The register runs in-process. Nothing to start, connect to, or keep alive. This replaces genro-nodaemon.

Sticky per user

Each user always returns to the same worker, so their session state stays coherent — no shared session store to configure.

WebSocket, natively

ASGI means real WebSocket support, which werkzeug/WSGI never had.

The site never changes

Same root.py, same auth, same sessions. genropy-asgi changes how the site is served, not what it is.

Quick Start

Install

pip install genropy-asgi                              # from PyPI
pip install git+https://github.com/genropy/genropy-asgi.git  # latest from GitHub

genro-asgi is pulled in automatically. GenroPy itself must be installed and configured at runtime (the usual ~/.gnr/environment.xml plus an existing site) — genropy-asgi runs your site, it does not create one.

Run your site (single process)

gnrasgiserve mysite # the same instance name you use with gnrwsgiserve
# → site on http://0.0.0.0:8080/index

That is the whole drop-in. Your site now runs on uvicorn. Common options:

gnrasgiserve mysite -p 9000              # a different port
gnrasgiserve mysite -H 127.0.0.1 -p 9000 # host + port
gnrasgiserve mysite --reload             # auto-restart on file changes
gnrasgiserve mysite --nodebug            # debug off

Run it as a pool

gnrasgiserve mysite --workers 2 -p 8080

Same command, one extra flag. Now a commander routes each user to a stable worker and the pool grows under load.

The "aha": watch it live

The server exposes a JSON snapshot of the pool. Curl it and you see the workers, their status and how many users each holds:

curl -s http://127.0.0.1:8080/_server/monitor_state | python3 -m json.tool
"workers": [
  {"id": "default_01", "status": "running", "users": 5},
  {"id": "default_02", "status": "running", "users": 6},
  {"id": "default_03", "status": "running", "users": 4}
]

Key insight: single and pool serve the same site the same way. The only difference is how many processes run it and who routes to them. You develop in single, you deploy in pool, and the site code is identical.

Single vs. Pool

genropy-asgi has two shapes, chosen at launch. Here is exactly how they differ.

 SinglePool (multi)
Launchgnrasgiserve mysitegnrasgiserve mysite --workers N
Processesoneone commander + N workers
Routingnone (one process)sticky per user (sticky_cid cookie)
Site registerin-process, this processin-process, per worker (local)
Concurrencyone site, one thread poolN sites, N thread pools
Scalingfixedthe pool grows under load
Replacesgnrwsgiservea multi-process load-balanced deploy
Singlethe default

One process hosts the site and is the commander of itself. Right for development, for a handful of concurrent users, and as the exact drop-in for gnrwsgiserve. Simple to run, simple to debug.

Poolfor many concurrent users

A commander supervises N worker processes, each hosting the same site with its own in-process register and its own thread pool. Because a synchronous GenroPy site saturates one process at a few users, running it in several processes multiplies throughput. Each user is pinned to one worker, so the session state that lives in that worker's register stays coherent.

How a request flows

Request COMMANDER reads sticky_cid → user's worker WORKER (this user's) runs GnrWsgiSite in a thread in-process register connections · pages · stores datachanges (local queue)

No sticky_cid cookie yet (a fresh visitor)? The commander sends them to the welcome worker, which mints the cookie on first connection. From then on every request from that user lands on the same worker.

Global state is coherent across the pool — eventually. The legacy globalStore() rides the framework's store rail: a write on one worker reaches the others after one channel round-trip (the commander is the single writer, it pushes to every replica; a late worker is seeded at announce). Eventual, not synchronous, which suits the real uses (cache-invalidation timestamps, flags). Per-user and per-page state is pinned to one worker and immediately coherent there.

How the Pool Scales

The commander starts with the configured number of workers and grows the pool under load. Two rules govern it — both simpler than they sound.

Placement: fill the idle ones first

A just-logged-in user goes to the first worker with room. "Room" means strictly under the worker's user cap; a worker at cap is full. Idle workers fill before the pool ever grows.

Scaling: grow only when the whole group is full

The pool spawns a new worker only when every routable worker is at or above 80% of its cap — the group as a whole has no room. One hot worker with idle capacity elsewhere never triggers a spawn. A spawn already in flight is waited for, not stacked on top of another.

Worked example — cap 6 per worker (80% threshold = 4.8, so a worker has room to grow the pool while it holds 4 or fewer). 15 users log in one after another:

users 1–5 users 6–10 users 11–15 W1 · 5 → crosses 80% → spawn W2 W1 · 5 W2 · 6 idle slots fill, no spawn W1 · 5 W2 · 6 W3 · 4 both full → spawn W3 result: 3 workers holding 5 / 6 / 4

This is the observed behaviour of a real run: 15 distinct users settle onto three workers, not twelve. Idle workers fill before new ones spawn.

The one timing caveat

A fresh worker takes a few seconds to boot a full GnrWsgiSite. Under a genuine burst — many logins arriving faster than a worker can announce — the extra logins pile onto the last full worker until the new one is ready, then routing resumes. The commander deliberately never stacks a second spawn while one is in flight. At a realistic login rate the pool simply settles to the expected size.

Guests and the welcome worker

Not-yet-logged-in visitors are always served by the first worker of the default group — the welcome worker — which also mints the sticky_cid cookie. Because it carries guests too, its logged-user cap (max_users_first) is usually set lower than the others' (max_users_other).

Groups: Run Several Versions at Once

So far the pool has been one group of interchangeable workers. But a group is more than a pool — a group is a runtime. Each group declares its own Python interpreter, so different groups can run different versions of the same site side by side, behind one commander, on one address.

The use case: a canary rollout, or a blue/green deploy, or pinning a set of users to a specific build. Group green runs the stable version from one virtualenv; group canary runs the next version from another. Each user is routed to a group by their avatar — and when their group changes, the commander migrates their session across, live.

COMMANDER routes by avatar xgroup group "green" (default) python=/venvs/stable/bin/python green_01 v1.stable green_02 v1.stable group "canary" python=/venvs/next/bin/python canary_01 v2.next

Declaring groups in the config

Groups are a groups child of the application. Each group has a code, a workers count, and an optional python — the interpreter path for that group's worker processes. Omit python and the group runs on the current interpreter.

class ServerConfiguration(AsgiConfigBuilder):
    def main(self, root):
        root.server(host="127.0.0.1", port=8080)
        root.middleware()
        apps = root.applications(default="site")
        app = apps.application(
            code="site",
            app_class=GenropyCommanderApplication,
            worker_app_class="genropy_asgi.spa.genropy_worker_application:GenropyWorkerApplication",
            app_args={"source": "mysite", "debug": ""},
            commander_url="http://127.0.0.1:8080",
        )
        # default = the welcome group: guests and unrecognized xgroups land here
        groups = app.groups(default="green")
        groups.group(code="green",  workers=2, python="/venvs/stable/bin/python")
        groups.group(code="canary", workers=1, python="/venvs/next/bin/python")

How a user reaches a group

A user is routed to a group by the xgroup field of their avatar. The commander reads it at login (it rides the change_connection_user event). An avatar with no xgroup, or one naming a group that is not declared, falls back to the default group — the welcome/base version. When a user's xgroup differs from the worker currently holding them, the commander migrates the session to the right group's worker, live (the source ships an opaque session package, the destination installs it).

ParameterMeaning
groups(default=...)Names the welcome group: guests and unrecognized xgroups go here. Must be a declared group.
group code=The group name (the routing key matched against the avatar's xgroup). Worker names are prefixed with it (green_01, canary_01).
group workers=The group's initial pool size (it grows under load, per group).
group python=The interpreter for this group's worker processes. Point it at a venv to run a different version. Omitted = the current interpreter.

venv, Podman, Docker

The lever the framework gives you is one interpreter path per group (python=). That maps directly onto isolation strategies:

virtualenv — native

Point python= at each venv's bin/python. Every group installs its own version of the site and its deps in its own venv. This is the built-in path, no extra tooling.

Podman / Docker — a pattern, not a built-in

There is no container spawner in the framework: python= launches a local interpreter, not a container. To containerize, run the commander in one container and each group's workers in others, wired over the commander's HTTP back-channel — the workers only need to reach commander_url.

Honest scope: per-group virtualenvs are a first-class, tested feature. Per-group containers are a deployment pattern you assemble around the same commander_url seam — the framework does not launch Podman/Docker for you. And the avatar → xgroup mapping is your GenroPy site's job: the commander only reads the value.

One Server, Many Apps

The legacy site is not the only thing the server can host. It is a multi-app ASGI server: you mount the site on the root and any number of other apps beside it, each on its own path prefix. One process, one port, one origin — and every app can talk to the same GenroPy database.

The use case: your legacy site serves the UI on /. Alongside it you expose a modern REST API on /api/, an MCP endpoint on /mcp/ for AI agents, and a native async WebSocket on /live/ — all backed by the same database the site already uses, all under the same host and port.

AsgiServer — one origin host:port, cookies shared / (legacy site) GnrWsgiSite UI + pages /api/ OpenApiApplication REST + Swagger /mcp/ McpOpenApiApplication AI agents /live/ async app WebSocket ↑ same GenroPy database

A REST / OpenAPI endpoint on the same database

GenropyProxyOpenApiApplication hosts a GnrApp behind an OpenApiApplication: your routing class exposes plain methods as REST, and the mixin closes the db connection on the right (executor) thread after each call. Point it at the same instance the site serves — same database, different surface.

from genropy_asgi.proxy import GenropyProxyOpenApiApplication

# mounted on /api/ — REST + Swagger UI, backed by the "mysite" GnrApp
api = GenropyProxyOpenApiApplication(
    instance="mysite",               # same GenroPy instance as the site
    routing_class=MyApi(),          # your @route-decorated methods
    docs="swagger",               # swagger | redoc | off
)
server.mount("api", api)

The same class as MCP, for AI agents

The same routing class can be exposed as an MCP endpoint with McpOpenApiApplication: the MCP engine points at the same router, so one set of methods serves both a REST client and an AI agent — no second implementation.

from genro_asgi.applications.openapi_application import McpOpenApiApplication

server.mount("mcp", McpOpenApiApplication(routing_class=MyApi(), api_name="tools"))

Why "same origin" is the whole point

Because every app lives under one host and port, the browser sees one origin: no CORS to configure, and the legacy session cookie is sent to every path. A legacy GenroPy page can therefore reach a new ASGI endpoint directly — a fetch("/api/…") from page code, or an <iframe src="/live/…"> embedding a modern async view inside the classic UI — with the user's session already authenticated. New surfaces grow next to the old pages without a second deployment, a second domain, or a token dance.

Honest scope: "same origin" is what makes embedding easy (shared cookies, no CORS) — the framework does not inject ASGI markup into legacy pages for you. You embed from the legacy side (a fetch, an iframe, a script) pointing at the mounted path. In a pool, mount the extra apps on the same server as the commander; the commander forwards only the site's own traffic to the workers and serves its neighbours locally.

The Daemon Is Gone

A GenroPy site talks to its register for every piece of shared state: which connections and pages exist, who is logged in, the pending datachanges to push to the browser, the page/user/connection/global stores. It reaches it through site.register, one command at a time.

Historically that register was a daemon — a separate process on a wire (Pyro4, later the genro-nodaemon TCP daemon). Every command was serialised onto that wire.

genropy-asgi removes the wire. The register is served entirely in-process by GenropyRegisterClient: every site.register command is answered from the hosting application's own registries, surface and stores. There is no daemon to start, connect to, or keep alive.

How the legacy still finds it

The package provides the gnr.web:daemon entry point. The legacy gnr.web.daemon switcher imports the module named by that entry point and installs it as gnr.web.daemon, so the legacy imports resolve to genropy-asgi — with nothing behind them. The GnrWsgiSite builds the register directly at site.register, no connection at construction.

# pyproject.toml — this is what wires the daemonless register in
[project.entry-points."gnr.web"]
daemon = "genropy_asgi.siteregister"

The switch model (datachanges)

GenroPy pushes datachanges to browsers: a record edited by one user must reach every page subscribed to that table — possibly on another worker. genropy-asgi handles this with the switch model:

  • a datachange queue lives locally on the worker that owns the page;
  • a page drains its own pending list on its own worker — no pull RPC back to a central daemon;
  • when a change must cross to a page on another worker, the commander forwards it to that worker's /datachange_in endpoint, where it lands on the local queue like any other.
WORKER A user edits a record COMMANDER fans out to subscribing workers only WORKER B /datachange_in → local queue

Over-notifying a worker that does not subscribe is harmless — it is dropped at the fan-out. A page reads its pending list where the page lives, never over the network.

Config & CLI

Every gnrasgiserve option

OptionDefaultWhat it does
instancerequiredGenroPy instance/site name, or a path to a site directory.
-H, --host0.0.0.0Bind host.
-p, --port8080Listening port.
--reloadoffAuto-restart on file changes.
--nodebugoffDisable debug mode.
--workers N0Serve through a commander with N workers. 0 = single. Ignored with --config.
--config FILEbuilt-inA server config.py that carries the pool shape; the CLI instance still wins.

When you need a config file

The CLI's --workers starts a pool but uses the framework's default per-worker caps. To set the caps (max_users_first / max_users_other) or a max_workers ceiling, use a config file — a ServerConfiguration subclassing genro-asgi's AsgiConfigBuilder:

import os
from genro_asgi.config import AsgiConfigBuilder
from genropy_asgi.spa.genropy_commander_application import GenropyCommanderApplication

SITE = os.environ.get("GNR_ASGI_PATH") or "mysite"
PORT = int(os.environ.get("GNR_ASGI_PORT") or 8081)

class ServerConfiguration(AsgiConfigBuilder):
    def main(self, root):
        root.server(host="127.0.0.1", port=PORT)
        root.middleware()
        apps = root.applications(default="site")
        apps.application(
            code="site",
            app_class=GenropyCommanderApplication,
            worker_app_class="genropy_asgi.spa.genropy_worker_application:GenropyWorkerApplication",
            app_args={"source": SITE, "debug": ""},
            workers=1,               # start with one; the pool grows under load
            max_users_first=6,       # the first worker also hosts guests
            max_users_other=6,       # every other worker
            commander_url=f"http://127.0.0.1:{PORT}",
        )

Launch it either way — the config brings the pool shape, the CLI instance wins:

gnrasgiserve mysite --config pool_config.py -p 8081  # via gnrasgiserve
python -m genro_asgi serve pool_config.py            # via the genro-asgi core CLI

Environment variables (read by the built-in recipe)

VariableDefaultControls
GNR_ASGI_PATHrequiredThe site path (the CLI sets it from the instance).
GNR_ASGI_HOST127.0.0.1Bind host.
GNR_ASGI_PORT8000Listening port.
GNR_ASGI_DEBUGtrueDebug mode; empty string turns it off.
GNR_ASGI_WORKERS00 = single; N > 0 = a commander with N workers.

Cheat Sheet

I want to…

I want to…Do this
Serve my site (drop-in for gnrwsgiserve)gnrasgiserve mysite
Pick a port and hostgnrasgiserve mysite -H 127.0.0.1 -p 9000
Auto-restart while developinggnrasgiserve mysite --reload
Run a pool of 3 workersgnrasgiserve mysite --workers 3
Set per-worker user capsWrite a config file, launch with --config
Watch the pool growcurl .../_server/monitor_state
Scrape Prometheus metricscurl .../metrics
Install the latest from GitHubpip install git+https://github.com/genropy/genropy-asgi.git
Free a stuck portlsof -tiTCP:8080 -sTCP:LISTEN | xargs kill
Build the docspip install -e .[docs] && cd docs && make html
Add a REST/OpenAPI endpoint on the site's dbserver.mount("api", GenropyProxyOpenApiApplication(instance="mysite", routing_class=MyApi()))
Expose the same methods to AI agents (MCP)server.mount("mcp", McpOpenApiApplication(routing_class=MyApi()))
Embed a new ASGI view in a legacy pageSame origin: fetch("/api/…") or <iframe src="/live/…"> — cookies shared, no CORS

The moving parts

NameRole
gnrasgiserveThe CLI. Resolves the site, starts the server. Single, or a pool with --workers.
GenropySpaApplicationThe single: one process hosts the site and is its own commander.
GenropyWorkerApplicationA pool child: hosts the site as one worker in a commander's pool.
GenropyCommanderApplicationThe commander (a subclass of genro-asgi core's SpaMultiWorkerApplication): supervises the workers, routes by sticky cookie, and serves the site-wide /metrics endpoint.
GenropyRegisterClientThe in-process, daemonless register the legacy talks to via site.register.
GenropyProxyOpenApiApplicationA GnrApp behind an OpenAPI app — mount a REST/MCP surface on the same database, beside the site.
sticky_cidThe opaque cookie the commander mints; the routing key that pins a user to a worker.

Gotchas

SymptomCause
Pool never growsNot every worker is over 80% of its cap yet — expected, one worker is enough.
Too many workers under a load testLogins arriving faster than a worker boots; pace them so each spawn announces first.
Session resets between requestsClient dropping cookies — routing needs sticky_cid to persist.
Shared global value lags on another workerExpected: the global store is eventually coherent — a write propagates after one channel round-trip, not synchronously.
Serving old codeA pool from an earlier launch is still listening — kill the port and relaunch.