When you run pyrpc dev app.main, what should happen? A server starts, files are watched, types are regenerated — but should there be a prompt? Should you be able to type procedures and see every RPC method registered in your running server? Should inspect add show you the parameter types and docstring without curling an endpoint?
These questions turned into a deeper architectural debate than we expected. This post walks through the three designs we considered, the one we built, and the trade-offs that shaped the final implementation.
The problem
Before this change, pyrpc dev started a uvicorn subprocess, watched files, and regenerated TypeScript types — all silently. There was no way to see what procedures were registered, inspect their signatures, or even confirm the server had picked up the latest changes. The only feedback loop was: edit a file, check the watcher output, then test via curl or the frontend.
The pyrpc shell command existed as a separate network-based REPL — it connected to a running server over HTTP, fetched the schema, and let you call procedures interactively. But it was a separate process requiring a separate terminal. Developers wanted everything in one session.
We had three design options, each with very different architectures.
Option 1: Separate CLI client
The first model is what tools like redis-cli, psql, andmongosh use. Two terminals:
# Terminal 1 pyrpc serve app.main # Terminal 2 pyrpc shell --url http://localhost:8000
The shell is a separate process that connects over HTTP, downloads the schema, and lets you call procedures remotely. It works against any server — localhost, staging, or production — using the same protocol that frontend clients use.
Strengths: works everywhere the protocol works, no special server logic, same tool for all environments. This is how most mature infrastructure tools handle interactive access.
Weaknesses: requires a server to already be running, first call is slow (schema fetch over HTTP), can’t do compile-time validation, and is unusual for RPC frameworks (tRPC, gRPC, ConnectRPC don’t ship shells).
We already had this model working (pyrpc shell was built in the CLI overhaul). But it wasn’t what developers were asking for. They wanted something that felt like rails console or django-admin shell — a developer console attached to the running dev server, not a separate network client.
Option 2: Embedded REPL inside the server process
The second model is what Python itself uses (python),node, and irb — the REPL lives inside the same process as the application. Direct memory access, zero network calls, instant responses.
Strengths: registry is already in memory, no serialization, no HTTP overhead, instant startup.
Weaknesses: uvicorn blocks the main thread — you can’t run input() after uvicorn.run(). Server logs and REPL prompts fight over stdout — imagine kubectl logs showing[pyrpc] > prompts interleaved with request logs. Cannot connect remotely. Harder to run in Docker (no TTY in background). Doesn’t fit cloud deployments.
We rejected this model for one big reason: we don’t want production servers to have any concept of a console. The dev server should be a standard uvicorn instance that has no idea a developer is typing commands at it.
Option 3 (chosen): Dev server + interactive console
The model we built is a hybrid. The server runs as a subprocess (standard uvicorn, no modifications). The parent process imports the module directly to access the in-memory registry. A file watcher runs in a daemon thread. The main thread runs an interactive console via a simple input() loop.
$ pyrpc dev app.main ✓ Dev server for app.main ✓ Endpoint: http://127.0.0.1:8000/rpc ✓ Types: node_modules/@pyrpc/types/src/index.ts ✓ Watching 3 directories for Python changes pyrpc> procedures pyrpc> inspect add pyrpc> generate pyrpc> exit
Strengths: console doesn’t compete with server logs (separate stdout), registry access is instant (in-memory, not HTTP), works for local development where developers spend most of their time, doesn’t couple production servers to console logic, and clean shutdown viastop_event.
Weaknesses: registry can diverge from the server (more on this below), no tab completion yet, can’t connect remotely (that’s what pyrpc shell is for).
Architecture
The implementation lives in the dev command of pyrpc-codegen. Three components run concurrently:
1. Server subprocess
A standard uvicorn instance started via subprocess.Popen with--reload. The parent process generates a temporary Python file that creates PyRPCAsgiApp(default_router) and passes its path to uvicorn. The parent keeps the process handle for terminate() on shutdown and stores the startup args for the restart command.
Why subprocess and not in-process threading? uvicorn.run()blocks the main thread. Threading uvicorn is possible but the GIL, signal handling, and graceful shutdown become complex. A subprocess gives clean process isolation: kill it, restart it, ignore its stdout.
2. Watcher thread
A daemon thread running watchfiles.watch() — a Rust-backed file watcher using inotify, FSEvents, or ReadDirectoryChangesW under the hood. Passes a threading.Event as stop_event so the main thread can signal it to stop on exit. Uses yield_on_timeout=True so the loop periodically wakes up to check the stop flag.
On file changes, the watcher calls regenerate() which re-imports the module, refreshes the parent’s default_router, and writes updated TypeScript types.
Why daemon thread? Daemon threads are automatically killed when the main thread exits. No need to .join() or cleanly shut down. The thread only does file I/O and importlib.reload() — no resources that need cleanup.
3. Console loop
A _DevConsole class that runs a simple while True: input()loop in the main thread. Commands are dispatched via a dict — O(1) lookup, easy to extend. Output is formatted with Rich tables.
pyrpc> procedures
┌─────────────────────────────────────────────────────┐
│ Procedures (3 total) │
├─────────┬──────────────────┬─────────┬────────────────┤
│ Name │ Params │ Returns │ Doc │
├─────────┼──────────────────┼─────────┼────────────────┤
│ add │ a: int, b: int │ int │ Add two nums │
│ greet │ name: str │ str │ Say hello │
└─────────┴──────────────────┴─────────┴────────────────┘
pyrpc> inspect add
add
Doc: Add two numbers.
Returns: int
Parameters (2):
a: int
b: intThe console reads the parent process’s default_router directly — no network calls, no serialization, instant access. This works because the parent process imports the user’s module at startup and re-imports it on every file change.
Why not cmd.Cmd or prompt_toolkit?cmd.Cmd.cmdloop() catches KeyboardInterrupt internally and raises SystemExit, which is awkward with threading.prompt_toolkit would give tab completion and history but adds a dependency. A raw input() loop is zero-dependency and works reliably across platforms.
Threading model
The three components run with different thread lifetimes:
Main Thread Watcher (daemon) Subprocess
───────────── ──────────────── ──────────
dev() starts
├─ import module
├─ regenerate()
├─ Popen(uvicorn) ───────────────────────────────► uvicorn runs
├─ Thread(watcher) ──────► watchfiles loop
│ ├─ on change
│ │ └─ regenerate()
│ └─ check stop_event
├─ Console.run()
│ ├─ input() blocks
│ ├─ user types "exit"
│ └─ _running = False
│
└─ finally:
├─ stop_event.set() ────► thread exits
├─ proc.terminate() ─────────────────────────► uvicorn killed
└─ proc.wait()Key design points:
- Non-blocking lock:
regenerate()usesthreading.Lock.acquire(blocking=False)so concurrent calls from the watcher andgeneratecommand don’t race onimportlib.reload()(which is not thread-safe). - Event-driven stop:
threading.Eventis passed towatchfiles.watch(), which checks it internally in the Rust polling loop. Whenset()is called from the main thread,watch()stops yielding on its next internal check. - Daemon thread: If the main thread crashes, the watcher thread dies automatically. No orphaned threads, no resource leaks.
The reload problem (and how we fixed it)
The first version of regenerate() used a double-reload pattern:
# Old approach - smell importlib.reload(mod) default_router._procedures.clear() importlib.reload(mod)
Why the double reload? Router.register() uses dict assignment, so same-named procedures are overwritten. But procedures removed from the source file persist after reload because nothing removes them. The clear() between two reloads drops the stale entries, then the second reload re-registers only the current ones.
This works but it’s fragile. If the module has a syntax error, you’ve cleared the registry and can’t populate it. The user sees zero procedures.
The fix was Router.reload_module() — a new method on the coreRouter class in pyrpc-core:
def reload_module(self, module_path: str) -> bool:
old = dict(self._procedures) # snapshot
self._procedures.clear() # clear
try:
importlib.reload(mod) # single reload → @rpc fires on clean slate
except BaseException:
self._procedures.update(old) # rollback on failure
raise
if not self._procedures:
self._procedures.update(old) # restore if nothing registered
return False
return TrueOne reload, atomic swap, automatic rollback on failure. If the module has a syntax error, the old registry is preserved. If the module exports no procedures, the old registry is preserved. The user never sees an empty procedure list unless they explicitly removed everything.
The registry divergence problem
The biggest open architectural concern is registry divergence. The parent process and the server subprocess each rebuild their registries through completely different mechanisms:
- Parent:
importlib.reload()in theregenerate()call - Server: fresh Python process started by uvicorn
--reload
If the parent’s reload succeeds but the server’s import fails (or vice versa), the two registries diverge. The console’s procedurescommand could show procedures that the HTTP server doesn’t actually serve.
We don’t eliminate this risk in the current implementation, but we mitigate it in two ways:
- The parent and server import the exact same module file. If the file compiles, both reloads should succeed. If it doesn’t compile, both reloads should fail.
- The
reload_module()rollback means a failed parent reload preserves the old registry, which is still consistent with whatever the server is serving.
A future improvement could verify the registries match after regeneration by calling the server’s GET /rpc endpoint and comparing the result against the parent’s default_router. A mismatch would print a warning. We may add this once the call command (in-console RPC execution) lands, since that command already needs the HTTP client.
Commands reference
Command Description ───────────────────────────────────────────────────── help Show available commands and usage procedures List all registered RPC procedures procs Alias for procedures inspect <name> Show details for a specific procedure generate Manually trigger type regeneration types Show path to generated TypeScript types restart Kill and restart the dev server exit / quit Stop the dev server and console
Known limitations
- No tab completion. The console uses raw
input()— no autocomplete for procedure names or argument hints.prompt_toolkitwould solve this but adds a dependency we haven’t committed to yet. - Redundant watch loops. Both the watcher thread and uvicorn’s
--reloadwatch for file changes. A future optimization could unify them: the watcher signals uvicorn to restart instead of relying on a second watch loop. restartis no-op in--types-onlymode. If the server wasn’t started, there’s nothing to restart.- No cross-platform stdin handling.
input()with Rich ANSI codes works on Windows Terminal and VS Code terminal but may have issues with PowerShell ISE or oldcmd.exe.
What’s next
The immediate roadmap:
- In-console RPC calls. Type
call add(1, 2)and get the result displayed in the console. Uses HTTP to the running server. - Tab completion.
prompt_toolkitintegration for procedure names, argument hints, and command history. - Registry verification. Compare parent and server registries after reload and warn on mismatch.
- Unified watch loop. One watcher that triggers both type regeneration and server restart, eliminating the redundant
--reloaddependency.
The full implementation is on the feat/dev-console branch. All 45 Python tests pass.

pyRPC