SUPERSEDED (2026-08-22): this page predates the bridge-rebase-new-core rebase — the cookie is now spa_connection_id (the site's own connection id), the worker-count selector and memory_limit_mb are gone, the pool is born from the recipe. Trust the code, not this page.

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. Two things stand between it 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. Because the work is CPU-bound under the GIL, that process saturates while the machine still has cores to spare — and spreading the load meant an external balancer plus session state shared 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 answered in-process.

The idea underneath

The bridge is one ASGI application — GenropySpaApplication — and it plays two roles at once. It is the front that terminates HTTP, mints the sticky cookie and decides which worker owns a user; and it hosts (or supervises) the workers, each of which runs a real GnrWsgiSite in a thread executor. The single process is not a different class or a different code path: it is the same front with workers=0, local_worker=True, holding its one worker inside itself. That is the whole trick, and it is why development and production behave alike.

Nothing in your site changes. Same root.py, same packages, same authentication, same sessions. genropy-asgi changes how the site is served, never what it is. The site keeps calling site.register exactly as before — only now something in the same process answers.

browsers HTTP · one host, one port FRONT — GenropySpaApplication reads sticky_cid · places the user · forwards the call WORKER 1 — reception GnrWsgiSite register in-process WORKER 2 GnrWsgiSite register in-process WORKER 3 GnrWsgiSite register in-process no daemon no balancer

What you actually get

Drop-in for gnrwsgiserve

Same instance name, unmodified code. gnrasgiserve mysite and you are on uvicorn.

Scale across processes

--workers N turns the same command into a front plus a pool of worker subprocesses that grows under load. No external balancer.

No register daemon

The register is answered in-process by GenropyRegisterClient. Nothing to start, connect to, or keep alive. This replaces genro-nodaemon.

Sticky per user

Each user returns to the same worker, so the session state living in that worker's register stays coherent — no shared session store to configure.

Native ASGI beside the site

The server is multi-app: a REST surface, an MCP endpoint or an async view can live next to the legacy site, same host, same port, same database.

One class, two shapes

Single and pool are the same application under different configuration — not two code paths that can drift apart.

Quick Start

What you need first

  • Python ≥ 3.11.
  • A working GenroPy environment~/.gnr/environment.xml pointing at your GenroPy tree, the same file gnrwsgiserve needs.
  • An existing site — genropy-asgi serves a site, it does not create one.
  • psycopg2, if the site is on PostgreSQL (install GenroPy with its pgsql extra, or psycopg2-binary).

Use a dedicated virtualenv. Installing genropy-asgi registers a gnr.web:daemon entry point that replaces the legacy gnr.web.daemon module for every program in that environment. That is the point of the package — the daemonless register — but it means an environment where you also run the classic daemon-based stack is the wrong place to install it.

Install

# both from git, in this order — see the warning below
pip install git+https://github.com/genropy/genro-asgi.git
pip install git+https://github.com/genropy/genropy-asgi.git

# from checkouts, for development
pip install -e '<path>/genro-asgi'
pip install -e '<path>/genropy-asgi[dev]'

Take genro-asgi from git too, not from PyPI. genropy-asgi only requires genro-asgi>=0.33.0, so a plain install resolves the published release — which predates both the live monitor and the fix that makes a protected route lead to the login instead of answering 403. The bridge runs on it, but you would be reading this page against a server that does not have what it describes.

GenroPy itself is a runtime requirement: the worker builds a GnrWsgiSite and imports gnr.* when it runs, never at build time. Install it in the same virtualenv (with its pgsql extra for a PostgreSQL site).

Serve your site

gnrasgiserve mysite
# → http://127.0.0.1:8000/index  (the built-in defaults)

That is the drop-in. Common options:

gnrasgiserve mysite -p 9000                 # another port
gnrasgiserve mysite -H 0.0.0.0 -p 9000      # reachable from outside
gnrasgiserve mysite --nodebug               # debug off
gnrasgiserve mysite --workers 2             # a pool of 2 worker subprocesses

--reload is accepted for surface compatibility with gnrwsgiserve and then ignored: the core server has no reloader. It prints a line saying so. Restart the process to pick up code changes.

The two macOS variables you do NOT need

You will find PGGSSENCMODE=disable and OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES presented as mandatory in older notes about this stack. They are not, and the reason is structural: both defend against fork(), and nothing here forks. A worker is a brand-new interpreter —

subprocess.Popen([self.executable, "-m", WORKER_ENTRY_MODULE], env=env, start_new_session=True)

— and os.fork appears nowhere in genro-asgi or genropy-asgi. The child does not inherit a half-open libpq connection, so there is nothing to segfault. Verified by serving a two-worker pool with both variables removed from the environment: workers announced, /index and /metrics at 200, no crash.

Those variables do belong to the classic GenroPy stack under gunicorn, which forks its workers. If you also run the classic stack for comparison, keep them there — just not here, where they are a ritual.

Check it is really up

curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/index    # → 200
curl -s http://127.0.0.1:8000/metrics                                # → the site counters
genropy_site_counters{counter="users"} 2
genropy_site_counters{counter="pages"} 2
genropy_site_counters{counter="connections"} 2

Those three counters are the whole site as the front sees it, aggregated across every worker. They are Prometheus exposition format, so any scraper reads them as-is.

Try It: the test_invoice_pg bench

This is the environment the bridge was actually exercised on — a real GenroPy site with a real PostgreSQL database and 332 user accounts, 32 of which share a known password so a load harness (or a curious developer with several browser profiles) can log in as many different people. Everything below is checked out from repositories you already have, except the database itself.

1. The site: it ships inside GenroPy

The instance lives in the genropy repository and is tracked there — nothing to write by hand:

genropy/projects/test_invoice/
├── instances/
│   ├── test_invoice/        # the SQLite variant
│   └── test_invoice_pg/     # the PostgreSQL one — this is the bench
│       ├── instanceconfig.xml
│       ├── root.py
│       └── site/
└── packages/                # invc, adm, sys …

instanceconfig.xml points at a PostgreSQL database named test_invoice_pg on localhost, and it is the only one of those files the repository actually tracks.

The site folder is not versioned. gnrasgiserve resolves a site, not an instance (PathResolver.site_name_to_path), and it looks in two places, in this order: <projects>/*/sites/<name>/ first, then <projects>/*/instances/<name>/ — but the second only works if that folder holds a root.py, in which case the site/ subfolder is created for you. Neither the site folder nor that root.py is in the repository, so a fresh clone dies with EntityNotFoundException: site test_invoice_pg not found and 77 tests exclude themselves. Copy a root.py into instances/test_invoice_pg/ and a siteconfig.xml (declaring mainpackage="invc" and dojo version="11") into its site/ — the ones from sites/invoice_demo/ are identical in substance.

2. The database: ask for a dump

The populated database is not in any repository — 35 MB of data, 332 rows in adm.adm_user. It travels as a gzipped plain-SQL pg_dump (~1.4 MB), which loads on any PostgreSQL version:

createdb test_invoice_pg
gunzip -c test_invoice_pg.sql.gz | psql -q -d test_invoice_pg

Why plain SQL and not the custom format. The dump was taken with PostgreSQL 17.7, and pg_restore refuses an archive written by a version newer than itself — on 16 a custom-format file will not open at all. The SQL file is text and carries no such limit. It has also been stripped of everything that would tie it to that machine or that version: no OWNER TO, no GRANT, and none of \restrict, \unrestrict or SET transaction_timeout — the last one exists only from 17 onwards. Verified by loading it into an empty database with ON_ERROR_STOP=1: no errors, same 332 users, same 75 tables, same password hashes.

The data is the bench database as it stands, so the 32 accounts already carry the shared password — you can log in straight after loading, without touching the fixtures in the next step.

3. The accounts: 32 users, one password

The accounts come with the instance, but with passwords nobody knows. The genropy-asgi repository carries the fixture that makes them usable, in benchmarks/test_users.zip. The handed-over dump already has it applied — run this only if you rebuilt the database from somewhere else:

cd benchmarks
unzip -o test_users.zip
psql -d test_invoice_pg -f set_pwd_a.sql
FileWhat it is
set_pwd_a.sql32 UPDATE statements in one transaction, one per name in usernames.txt, setting every password to the single letter a. This is the one you run.
adm_user_backup_….sqlThe pg_dump of adm.adm_user taken immediately before that overwrite, so the original passwords can be restored. It recreates the table from scratch — restoring means dropping adm.adm_user first, never replaying it onto a live table.
usernames.txtThe 32 names the harness logs in as: amelia.martin, james.wilson, grace.hall, …
usernames_all.txtAll 332 accounts in the database, for a wider run.

They are fictional accounts on a throwaway database: invented names, @testinvoice.com addresses, and the hash of one letter as a password. Fine for a bench, obviously not for anything reachable from outside your machine.

4. Run it — single process

gnrasgiserve test_invoice_pg -p 8098 --nodebug

Open http://127.0.0.1:8098/index and log in as any name from usernames.txt with password a. It is the same site you would get from gnrwsgiserve — that is the point.

5. Run it — a pool of two workers

gnrasgiserve test_invoice_pg -p 8098 --workers 2 --nodebug

Now the front supervises two worker subprocesses and they announce themselves over a unix-domain socket. In the log you will see one line per worker:

INFO: W:b7f498d8…: serving on uds:/…/gnrhub_…/hub.sock (pid 17782)
INFO: W:811bb8c2…: serving on uds:/…/gnrhub_…/hub.sock (pid 17781)

The site behaves identically. What changed is that your first visit got a sticky_cid cookie, and every later request of yours goes back to the same worker. Log in from two different browser profiles and you may well be served by two different processes — the fastest way to feel what the pool does.

6. Watch what is happening

The counters, always available:

curl -s http://127.0.0.1:8098/metrics

The live monitor is served by genro-asgi at /_server/monitor/, and every one of its routes is gated SERVER_ADMIN. The built-in recipe declares no administrator, so out of the box the monitor answers 401. To open it, launch with a config that declares one — and, because the user store encrypts at rest, a storage key as well:

# monitor_config.py
import os
from genro_bag.resolvers import EnvResolver
from genro_storage import StorageManager
from genro_asgi.config import AsgiConfigBuilder
from genropy_asgi.spa import GenropySpaApplication

SITE = os.environ.get("GNR_ASGI_PATH") or "test_invoice_pg"
PORT = int(os.environ.get("GNR_ASGI_PORT") or 8098)

class ServerConfiguration(AsgiConfigBuilder):
    def main(self, root):
        cfg = root.configuration()
        cfg.server(host="127.0.0.1", port=PORT)
        cfg.middleware()
        cfg.storage(app=StorageManager, storage_key=EnvResolver("GENRO_STORAGE_KEY"))
        auth = cfg.authentication()
        auth.admin_password(EnvResolver("GNR_ADMIN_PASSWORD"))
        cfg.applications().application(
            code="site",
            app_class=GenropySpaApplication,
            source=SITE,
            debug=False,
            workers=2,
            local_worker=False,
        )
# a Fernet key for the at-rest encryption of the user store
export GENRO_STORAGE_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
export GNR_ADMIN_PASSWORD=<choose one>

gnrasgiserve test_invoice_pg --config monitor_config.py -p 8098

Then open http://127.0.0.1:8098/_server/login_page in the browser, sign in as admin with that password, and go to /_server/monitor/. From a script it is a form post:

curl -s -c jar.txt -d 'identity=admin&password=<pwd>' \
     http://127.0.0.1:8098/_server/login
curl -s -b jar.txt http://127.0.0.1:8098/_server/monitor/snapshot

Honest scope. The monitor renders the generic panel for the site application today: you see the server, its sections and the mounted app, not a per-worker breakdown. The placement view — which user sits on which worker — exists inside the front but is not exposed over HTTP yet. For now, /metrics plus the launch log is what you read from outside.

7. The load harness, if you want numbers

benchmarks/ holds a stdlib-only harness: a faithful replay of a recorded browser session, driven at rising concurrency against a running instance. Nothing to install, and its README.md carries the recipe that works, including the traps. Run every script from inside that directory — they open their data files by relative name.

Single vs. Pool

Two shapes of the same application, chosen at launch. Here is exactly how they differ.

SinglePool
Launchgnrasgiserve mysitegnrasgiserve mysite --workers N
Configurationworkers=0, local_worker=Trueworkers=N, local_worker=False
Processesone (front + its worker inside it)one front + N worker subprocesses
Routingone worker, nothing to choosesticky per user (sticky_cid cookie)
Site registerin-process, this processin-process, one per worker
Concurrencyone site, one thread poolN sites, N thread pools
Pool sizefixed at onegrows under measured pressure, compacts when idle
Replacesgnrwsgiservea multi-process balanced deployment
Singlethe default

One process is the front and holds one worker. Right for development, for a handful of concurrent users, and as the exact drop-in for gnrwsgiserve: one thing to launch, one log to read, a debugger that sees everything. Note what it is not: it is not a special "simple mode" with its own code. It is the pool machinery with a worker that happens to live in the same process, so the protocol it exercises is the real one.

Poolfor many concurrent users

The front supervises N worker subprocesses, 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 busy users, running it in several processes multiplies throughput on the cores you already have. Each user is pinned to one worker, so the per-user and per-page state living in that worker's register stays coherent and local.

How a request finds its worker

  • A request arrives carrying the sticky_cid cookie → the front hands it to the worker that owns that connection.
  • No cookie yet (a fresh visitor) → the front sends it to the reception, the group's first routable worker, which mints the cookie on the first connection. Guests already live there, so a login happens where the guest is.
  • On login the reception either keeps the user, or hands them to a less occupied worker — see Scaling.

What is coherent, and when

Per-user and per-page state lives in one worker's register and is immediately coherent there: there is no distributed lock, because there is nothing distributed to lock.

Global state — the legacy globalStore() — rides the framework's store rail and is eventually coherent: a write on one worker reaches the others after one channel round-trip, and a worker joining later is seeded when it announces. That suits what global state is really used for (cache-invalidation stamps, feature flags). If you write a value on worker 1 and read it on worker 2 in the same instant, you may read the old one. That is by design, not a defect.

Datachanges across workers

A record edited by one user must reach every page subscribed to that table — possibly on another worker. The queue is local to the worker that owns the page: a page drains its own pending list in its own process, never over the network. When a change has to cross, the front forwards it to the destination worker's /datachange_in endpoint, where it lands on the local queue like any other. Over-notifying a worker that does not subscribe is harmless — it is dropped at the fan-out.

How the Pool Scales

The front starts with the configured number of workers and grows the pool on measured pressure, not on head counts. There is no per-user cap: an idle user costs about a megabyte and no CPU, so counting users answers the wrong question. Every worker reports its occupancy — a number in 0..1 built from three components, cpu, executor and memory — and the front decides from that.

Why occupancy, not counts. A GenroPy site is CPU-bound under the GIL, so the real limit is a worker's CPU and executor saturation, not how many sessions are pinned to it. A worker holding many idle users is not full; one with a few busy users can be. The pool grows when the work demands it, not when the guest list gets long.

Placement: reception first

Every visitor is born on the reception — the first active worker of the pool, where the guests live, so a login happens where the guest already is. On login the reception keeps the user while its own occupancy is under reception_threshold (default 0.5); over that, the user goes to the least occupied of the other workers still under admission_threshold (default 0.8).

The login itself never ships anything: the worker announces the re-label and the user's slice stays where it is, so the request that carried the login keeps finding its pages to the end. The move, when there is one, is executed after that decision — not in the middle of it.

Scale-up: only when the group cannot place well

A new worker is spawned when the pool has nowhere good to put the next user — no non-reception worker under admission_threshold (or, in a pool of one, the reception itself over its keep-threshold). One hot worker with idle capacity elsewhere never triggers a spawn. A spawn already in flight is waited for, never stacked on top of another.

Scale-down: compaction on a capacity ledger

The front keeps a ledger: C, what the pool may take (the reception up to its threshold, a whole gate each for the others), against O, what it currently holds. A worker is taken out only while C - O, read without that worker, stays above compaction_margin (default 1.5). Its users are drained onto the survivors and it retires. A drain that does not empty retires nothing: a worker still holding state is never killed. min_workers is the floor (default 1) and the reception is never compacted, so guests always have a home.

Reading the margin without the condemned worker is what gives the hysteresis: scale-up and scale-down cannot chase each other.

The knobs

KnobDefaultWhat it decides
workers0Initial pool size. 0 with local_worker=True is the single.
min_workers1Compaction floor — the reception alone.
max_workersNoneScale-up ceiling. None = unbounded.
reception_threshold0.5The occupancy the reception may reach before it hands logins on.
admission_threshold0.8The fraction of every resource a worker may use before it stops taking logins.
compaction_margin1.5The headroom that must survive on the ledger for a worker to be retired.
memory_limit_mbNonePer-worker memory budget feeding the memory component. None = derived from host RAM.

The one timing caveat

A fresh worker takes a few seconds to boot a full GnrWsgiSite. Under a genuine burst — logins arriving faster than a worker can announce — the extra logins pile onto the last worker until the new one is ready, then routing resumes. This is deliberate: the front never stacks a second spawn while one is in flight, because doing so under a load test is how you end up with a dozen workers and no memory.

Watching it from outside

Two windows, and it is worth knowing exactly what each one shows:

SurfaceAuthWhat it gives you
/metricsopenPrometheus counters for the whole site: users, pages, connections.
/_server/monitor/SERVER_ADMINThe live page: the server, its sections, and a card per mounted application.
/_server/monitor/snapshotSERVER_ADMINThe same data as JSON, polled by the page.

What is not there yet. The site application renders the generic monitor panel: no per-worker breakdown, no "who sits where" view. That placement map lives inside the front and is not published over HTTP. Until it is, the honest answer to "is the pool spreading users?" is the launch log plus /metrics — and the Try It tab shows how to open the monitor at all, which needs a declared administrator.

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 datachanges pending for a browser, the page/user/connection/global stores. It reaches all of it through site.register, one command at a time.

Historically that register was a daemon — a separate process on a wire, Pyro4 first, later the genro-nodaemon TCP daemon. Every command was serialised onto that wire, and the daemon was one more thing to launch, supervise and debug.

genropy-asgi removes the wire. GenropyRegisterClient answers every site.register command from the hosting application's own registries, surface and stores — in the same process, no serialisation, no connection at construction.

BEFORE — the daemon GnrWsgiSite site.register.… register daemon separate process Pyro / TCP NOW — in-process one worker process GnrWsgiSite site.register.… GenropyRegisterClient registries · surface · stores a call

How the legacy still finds it

The package declares a gnr.web:daemon entry point. The legacy gnr.web.daemon switcher imports the module named there and installs it as gnr.web.daemon, so every legacy import resolves to genropy-asgi's own module — with nothing behind it. The site builds its register directly at site.register, with no connection to open.

# pyproject.toml — this is the whole wiring
[project.entry-points."gnr.web"]
daemon = "genropy_asgi.siteregister"

This substitution is unconditional, and it is why genropy-asgi wants its own virtualenv: in any environment where it is installed, the classic daemon-based stack no longer has a daemon module to import.

The switch model, in one paragraph

GenroPy pushes datachanges to browsers: a record edited by one user must reach every page subscribed to that table, wherever it lives. Each worker owns the queue of the pages it holds; a page drains its own pending list in its own process. When a change must cross to a page on another worker, the front forwards it to that worker's /datachange_in endpoint, where it lands on the local queue like any other. No page ever pulls over the network, and no central process holds the queues.

One Server, Many Apps

The legacy site is not the only thing the server can host: it is a multi-app ASGI server. There are two ways to grow native ASGI surface next to the site, and both stay in one process, on one port, on one origin, against the same GenroPy database.

  • Beside — declare separate applications, each on its own path prefix (/api/, /mcp/…).
  • In place — add native routes to the site application itself, shadowing individual paths one at a time. This is the incremental-migration path.

Applications are declared in the configuration, not mounted by a call at runtime: the set of applications is fixed when the server is built. So "mounting" an app means adding one application(…) line to the recipe, with its own code and mount.

Beside: a REST / OpenAPI surface on the same database

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

# in the config recipe, beside the site
from genropy_asgi.proxy import GenropyProxyOpenApiApplication
from genropy_asgi.spa import GenropySpaApplication
from myproject.api import MyApi

apps = cfg.applications()
apps.application(code="site", mount="", app_class=GenropySpaApplication,
                 source=SITE, workers=2, local_worker=False)
apps.application(code="api", mount="api",           # → /api/
                 app_class=GenropyProxyOpenApiApplication,
                 instance=SITE,                    # the same GenroPy instance
                 routing_class=MyApi(),
                 docs="swagger")                   # Swagger UI under _meta/docs

The same class, exposed to AI agents

One routing class can serve both a REST client and an MCP client: McpOpenApiApplication points the MCP engine at the same router, so there is no second implementation to keep in sync.

from genro_asgi import McpOpenApiApplication

apps.application(code="mcp", mount="mcp",
                 app_class=McpOpenApiApplication,
                 routing_class=MyApi(), api_name="tools")

In place: replace one site path at a time

The apps above live under their own prefixes. The in-place pattern instead adds native routes to the site application, on paths the site already owns, and each one shadows the legacy handler for exactly its own path. The native surface grows while the site keeps serving everything not yet moved: no second deployment, no cut-over day.

from genro_routes import route
from genropy_asgi.spa import GenropySpaApplication

class MySite(GenropySpaApplication):

    @route(media_type="application/json")
    def sys_health(self):        # /sys/health is native from now on…
        return {"status": "ok"}
        # …/sys/customer, /sys/order, … still render on the legacy site.

Then point app_class at MySite in the recipe. This is the same seam the built-in /metrics route uses — it is a @route on the site application, nothing more.

It works because the host demultiplexes in two stages: the first path segment picks an internal root, then the full path is resolved in the application's own router. A structural miss inside a claimed root falls through to the legacy site — so claiming a root does not claim its whole subtree, and a single native route shadows only its own path.

Why "same origin" is the whole point

Because every application 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 view inside the classic UI — with the user's session already authenticated.

Honest scope. Same origin is what makes embedding easy; the framework does not inject ASGI markup into legacy pages for you. You embed from the legacy side, pointing at the mounted path. In a pool, the extra applications live on the front: it serves its own internal roots and its neighbours locally, and forwards the site's traffic to the workers.

Config & CLI

Every gnrasgiserve option

OptionDefaultWhat it does
instancerequiredGenroPy instance/site name, or a path to a site directory.
-H, --host127.0.0.1Bind host (the default comes from the built-in recipe).
-p, --port8000Listening port (same).
--nodebugoffTurns debug mode off.
--workers N0Serve through a front with N worker subprocesses. 0 = single. Ignored when --config is given.
--config FILEbuilt-inA ServerConfiguration carrying the shape (pool size, thresholds, extra apps). The CLI instance still wins.
--reloadoffaccepted, ignored Kept for surface compatibility; the core server has no reloader.

The environment variables the built-in recipe reads

VariableDefaultControls
GNR_ASGI_PATHrequiredThe resolved site path. The CLI writes it from the instance argument.
GNR_ASGI_HOST127.0.0.1Bind host.
GNR_ASGI_PORT8000Listening port.
GNR_ASGI_DEBUGtrueDebug mode; an empty string turns it off (--nodebug sets that).
GNR_ASGI_WORKERS00 = single; N > 0 = a front with N workers.

The CLI writes these before the server is built, which is why a custom config that reads GNR_ASGI_PATH still serves the instance named on the command line.

When you need a config file

--workers starts a pool on the framework's default thresholds. Write a config to tune them, to bound the pool, or to declare more applications. A configuration is a ServerConfiguration subclassing genro-asgi's AsgiConfigBuilder, and its main() writes one document: the listener, the middleware, then the applications.

# pool_config.py — the shipped example lives in examples/multiworker_config.py
import os
from genro_asgi.config import AsgiConfigBuilder
from genropy_asgi.spa import GenropySpaApplication

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):
        cfg = root.configuration()
        cfg.server(host="127.0.0.1", port=PORT)
        cfg.middleware()
        cfg.applications().application(
            code="site",
            mount="",                       # the site owns its absolute URLs
            app_class=GenropySpaApplication,
            source=SITE,
            debug=False,
            workers=2,                     # initial pool size
            local_worker=False,           # True (with workers=0) is the single
            min_workers=1,                 # compaction floor
            max_workers=None,              # None = unbounded scale-up
            reception_threshold=0.5,
            admission_threshold=0.8,
            compaction_margin=1.5,
            memory_limit_mb=None,          # None = derived from host RAM
        )

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

gnrasgiserve mysite --config pool_config.py -p 8081
python -m genro_asgi serve pool_config.py

mount="" is not decoration: a GenroPy site owns absolute URLs (/_rsrc, /sys, the whole dojo tree), so it cannot live under a /site prefix. Everything else you add goes under its own prefix, beside it.

Cheat Sheet

I want to…

I want to…Do this
Serve my site (drop-in for gnrwsgiserve)gnrasgiserve mysite
Pick host and portgnrasgiserve mysite -H 127.0.0.1 -p 9000
Run a pool of 3 workersgnrasgiserve mysite --workers 3
Serve the bench site with its 32 known accountsgnrasgiserve test_invoice_pg -p 8098 after psql -f set_pwd_a.sql — see Try It
Create the site folder the repository does not carryDrop a root.py in instances/<name>/ — see Try It
Tune the occupancy thresholdsWrite a config (reception_threshold, admission_threshold), launch with --config
Read the site counterscurl -s .../metrics
Open the live monitorDeclare authentication.admin_password + a storage_key, sign in at /_server/login_page, then /_server/monitor/
Read the monitor as JSONcurl -b jar.txt .../_server/monitor/snapshot (after the form login)
Install the current bridgepip install git+https://github.com/genropy/genropy-asgi.git
Free a stuck portlsof -tiTCP:8000 -sTCP:LISTEN | xargs kill
Build the Sphinx docspip install -e '.[docs]' && cd docs && make html
Add a REST surface on the site's databaseOne more application(…) line with GenropyProxyOpenApiApplication
Make one legacy path nativeSubclass GenropySpaApplication, add a @route, point app_class at it

The moving parts

NameRole
gnrasgiserveThe CLI. Resolves the instance, writes the environment, starts the server.
GenropySpaApplicationThe one application: the front that routes, and the host of the worker(s). Single and pool are this class under different configuration. It also serves /metrics.
GenropyWorkerThe worker role: hosts a GnrWsgiSite and runs its synchronous calls in a thread executor.
GenropyRegisterClientThe in-process, daemonless register the legacy talks to through site.register.
GenropyProxyOpenApiApplicationA GnrApp behind an OpenAPI application — a REST/MCP surface on the same database, beside the site.
sticky_cidThe opaque cookie the front mints: the routing key that pins a user to a worker.
/datachange_inThe worker endpoint where a datachange crossing from another worker lands on the local queue.

Gotchas

SymptomCause
The pool never growsOccupancy is still under the admission threshold — idle users do not move it, only real work does. 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 requestsThe client is dropping cookies: routing needs sticky_cid to persist.
A global value lags on another workerExpected: the global store is eventually coherent — one channel round-trip, not synchronous.
The monitor answers 401No administrator is declared. The built-in recipe has none; see Try It.
Boot fails on "requires installed key material"You declared an administrator without a storage_key: the user store encrypts at rest.
Code changes are ignored--reload does nothing. Restart the process.
The classic daemon stack brokegenropy-asgi is installed in that environment and replaces gnr.web.daemon. Use a separate virtualenv.
Serving old codeA server from an earlier launch is still listening. Free the port and relaunch.

Not there yet

So you do not go looking for what is not built:

  • Worker groups — several versions of the site behind one front, each on its own interpreter, users routed by avatar. Designed, not in the code: there is one pool.
  • The placement view over HTTP — which user sits on which worker. The front knows; nothing publishes it yet, and the monitor shows the generic panel.
  • A reloader — the core server has none, and --reload says so out loud.

The orchestration layer inside genro-asgi is being rebuilt as this is written: the machine described here is the one that serves traffic today, and it keeps serving it while the new one is assembled beside it.