← Back to Blog

Probing the server: how pyrpc dev knows uvicorn is already running

·6 min read

pyrpc dev does two jobs: it keeps TypeScript types in sync, and it runs your server. Sometimes your server is already running, you started it yourself, or you’re on a second terminal and you only want the type watcher. How does dev know not to start a second uvicorn?

One HTTP probe

The answer is a single request with a short timeout:

def _server_is_running(host: str, port: int) -> bool:
 try:
 import httpx
 resp = httpx.get(f"http://{host}:{port}/rpc", timeout=1.0)
 return resp.status_code < 500
 except Exception:
 return False

GET /rpc is the introspection endpoint that every pyRPC adapter exposes, so a successful response is a reliable “yes, a pyRPC server is alive here”. The< 500 check treats any server-side response, even a 404 from a non-pyRPC app on that port, as “something is listening, don’t bind the port again”. The except Exception blanket covers connection refused, timeouts, DNS failures, and anything else; any of those means nothing usable is there.

Why a probe, not a bind test

The obvious alternative, try to bind the port and see if it fails, has a real flaw: binding is not the same as running your app. A port can be occupied by an unrelated process, or your OS can briefly refuse a bind for reasons that have nothing to do with your server. The HTTP probe tests the property we actually care about: is the pyRPC API reachable at this address? A port can also be free while your server is mid-restart (uvicorn’s reloader closes the socket between processes), and a probe handles that transient state gracefully too.

The 1-second timeout keeps the probe from stalling startup on a dead or slow network path. A single retry is deliberately not performed, if the first probe fails, starting uvicorn is the safe fallback either way.

Attach mode

When the probe succeeds, dev prints a status line and skips uvicorn entirely:

if _server_is_running(host, port):
 console.print(
 f" [dim]○[/dim] server already running at "
 f"http://{host}:{port}/rpc, skipping uvicorn"
 )
else:
 server_proc = _start_uvicorn(module)
 server_managed = True
 console.print(f" [bold]pyRPC dev[/bold] http://{host}:{port}/rpc")

The server_managed flag matters downstream: it gates whether the interactive console’s restart command is allowed to touch the process, and whetherdev terminates anything on exit. When the server was already running, pyRPC is a guest, it attaches, regenerates types, watches files, and leaves your process alone.

The workflow it enables

This single probe is what makes the tool feel non-possessive:

  • Two terminals, one server. Start the app with your debugger, then run pyrpc dev in another terminal purely for type regeneration.
  • Docker-in-the-loop. The server runs in a container; dev on your host detects it over HTTP and stays out of the way.
  • Safe re-runs. Accidentally running pyrpc dev twice never results in a port conflict, the second invocation attaches.

Detection is intentionally narrow and fast. It is a hello, not an audit: one GET, one-second budget, and a decision about ownership that the rest of the CLI respects.

Read the full changelogfor the complete list of changes.