When pyrpc dev starts the server, it doesn’t wrap uvicorn in a Python API call or a temp file. It spawns uvicorn as a real subprocess with a command line you’d recognize, and by default that command line ends with--reload. This post walks _start_uvicorn, the two independent reload paths, the restart command, and when you’d want--no-reload instead.
The command dev builds
Everything happens in _start_uvicorn:
def _start_uvicorn(mod: str) -> subprocess.Popen:
"""Start uvicorn for module, return the Popen object."""
app_var = "app"
cmd = [
sys.executable, "-m", "uvicorn",
f"{mod}:{app_var}",
"--host", host,
"--port", str(port),
"--log-level", "error",
]
if reload:
cmd.append("--reload")
env = os.environ.copy()
env.setdefault("PYTHONPATH", cwd)
proc = subprocess.Popen(cmd, cwd=cwd, env=env)
proc._cwd = cwd # stash for restart
return procThree choices are worth unpacking. First, the app variable is hardcoded toapp: uvicorn is asked to run mod:app, the conventional ASGI variable name, no :other guessing, because _import_moduleand uvicorn are kept on the same contract. Second, PYTHONPATH is set to the working directory via env.setdefault, the same guarantee_import_module gives when it does sys.path.insert(0, os.getcwd()), so uvicorn can find your entry module regardless of how pyRPC was installed. Third, the process is spawned with cwd=cwd and the working directory is stashed on thePopen object as _cwd, because a later restart needs to reproduce the exact same execution context.
--log-level error keeps the terminal quiet, the same reason the early release notes cited “6 lines of reloader/server spam eliminated.” You see the pyRPC status line, not uvicorn’s banner.
Why reload is on by default
dev declares the reload flag with an explicit--reload/--no-reload pair defaulting to True:
reload: bool = typer.Option(True, "--reload/--no-reload", help="Uvicorn auto-reload")
Starting uvicorn with --reload mirrors what you’d do running uvicorn directly during development. But there’s a subtlety worth understanding: pyRPC hastwo independent reload paths, and they solve different problems. The debounced regen callback reloads your module in-process viadefault_router.reload_module purely to rebuild the TypeScript schema, it updates <client>/__pyrpc.d.ts and never touches the running server. uvicorn --reload, meanwhile, restarts the actual server process when imported Python files change, so a new import, a changed module attribute, or a decorator applied at import time takes effect in the serving process. Neither can replace the other: the schema refresh keeps types fresh without bouncing connections, while the server restart picks up code that only matters at process start.
Attach or start
Before starting anything, dev probes the port. _server_is_runningdoes an httpx GET on http://{host}:{port}/rpc with a 1-second timeout and treats any response below HTTP 500 as a running server. If a server answers,dev skips uvicorn entirely and runs the type watcher only, withserver_managed left False. That flag is what gates the interactive console’s restart command:
$ pyrpc dev ✓ types generated (3 procs) → ../frontend ○ server already running at http://127.0.0.1:8000/rpc, skipping uvicorn pyrpc> restart ○ server not managed by pyrpc
When dev does start the server, server_managed isTrue, and that managed process is terminated on shutdown, Ctrl+C in the dev console stops the subprocess before exiting.
The restart path
_DevConsole._restart is the manual escape hatch:
def _restart(self, _=""):
if not self.server_managed or not self.server_proc:
console.print(" [yellow]○[/yellow] server not managed by pyrpc"); return
console.print("[yellow]Restarting...[/yellow]")
self.server_proc.terminate(); self.server_proc.wait()
self.server_proc = subprocess.Popen(
self.server_proc.args,
cwd=getattr(self.server_proc, "_cwd", None),
)
console.print("[green]Restarted[/green]")terminate() sends SIGTERM; wait() ensures the process is actually gone before the port is reused, otherwise the restart would race uvicorn’s socket teardown. Then a new Popen is built fromself.server_proc.args. Reusing .args is deliberate: the command line was built once in _start_uvicorn, and replaying it guarantees the restarted server uses the same module, host, port, and --reload flag. The stashed _cwd is re-applied because .args alone wouldn’t carry the working directory.
The same terminate-wait-restart sequence runs automatically when the config watcher notices module changed in pyrpc.json: it kills the managed uvicorn and calls _start_uvicorn(new_module), but only whenserver_managed is true. If you attached to a server pyRPC doesn’t own, a module change just re-wires the type watcher, leaving the running server alone.
When to disable reload
--reload is the right default for a dev loop, but there are legitimate reasons to turn it off:
- You work in a large codebase and uvicorn’s reloader, which stat-polls the files it watches, adds visible churn, or restarts the server for files it doesn’t even import.
- You already run your own reloader on top (Docker Compose
watch, a file-sync tool, a hot-reload framework), and two reloaders double-restart or fight each other. - You only edit frontend code and want the server to stay up across saves; the type watcher keeps working either way, and without
--reloadnothing disturbs the running process.
pyrpc dev --no-reload leaves the type pipeline untouched: .pysaves still regenerate types through the debounced callback; only the server process stops auto-restarting. When it does need to restart, the console’s restartcommand, or a module change in pyrpc.json, still works, because that path never depended on --reload in the first place.
Read the full changelogfor the complete list of changes.

pyRPC