Backend Launcher¶
backend/run.py is a single entrypoint for launching the Erudi FastAPI backend on every
build target: macOS (Apple Silicon), Windows (CUDA/CPU), and Linux (CUDA/CPU). It is also
what you run locally in development. It emits newline-delimited JSON events on stdout so
the Electron shell can track backend state.
It is not a thin wrapper around uvicorn. If you touch the file, preserve the stdout protocol below.
Responsibilities¶
- Configure third-party libraries for deterministic, low-noise startup (Torch, tokenizers, MKL).
- Force the Windows selector event-loop policy for library compatibility.
- Normalize stdout/stderr to line-buffered mode so lifecycle events flush immediately.
- Detect PyInstaller bundles and relocate runtime data and logs to OS-appropriate, user-writable folders.
- Preserve the macOS
~/Library/Application Support/erudi/backend/prodsymlink behaviour. - Initialize multiprocessing with the
spawnstrategy before importing heavy modules (Torch, FastAPI) — the MLX engine depends on it. - Launch uvicorn on
127.0.0.1:27182(scanning 27182-27199 if busy) in a background thread, monitor readiness, and emit structured error events for crashes, timeouts, or port conflicts. - Shut down cleanly when the parent process dies, bounded so a quit is a quit.
Ports¶
Erudi's canonical port is 27182 — the leading digits of Euler's number e (2.7182…), a wink for an app built for erudites. It is also a safe default on every OS: unassigned by IANA, below every ephemeral range (Linux 32768-60999, Windows/macOS 49152-65535, plus the Windows Hyper-V/WSL exclusions inside that range), and clear of the crowded local LLM defaults (Ollama 11434, LM Studio 1234, vLLM 8000, llama.cpp and Tomcat 8080).
The whole Erudi footprint sits in the 271xx-273xx block:
| Range | Owner |
|---|---|
| 27182-27199 | FastAPI backend (CANONICAL_PORT + PORT_SCAN_COUNT = 18 in backend/run.py) |
| 27200-27299 | llama-server child process (CPU and CUDA engines) |
| 27300-27399 | mlx_vlm.server child process (MLX engine) |
The backend prefers 27182 and scans forward to 27199 on collision, stopping short of
27200 so it can never take a port from an inference pool. The resolved port is announced
in the starting and ready events, and the renderer uses the announced value — it
never assumes 27182.
Override the port on the command line:
BACKEND_PORT is development-only¶
The backend itself never reads BACKEND_PORT; it takes --port. The variable is read in
two places only:
scripts/dev/dev-start.sh, which uses it to kill whatever is on the port and to launch both processes;frontend/src/main.js, in development mode only, where the Electron main process assumes the backend is already running and health-checkshttp://127.0.0.1:${BACKEND_PORT}/erudi/health/(default 27182).
In a packaged build Electron spawns the backend itself and captures the real port from
the JSON events, so BACKEND_PORT has no effect there.
BACKEND_PORT is documented as dev-only in
backend/.env.example.
Checking which port answers¶
curl http://127.0.0.1:27182/erudi/health/
# {"status":"ok","message":"Backend is running","db":"ok"}
The trailing slash is required. If the renderer reports
ERR_CONNECTION_REFUSED on /erudi/health/, read the backend's ready event to see the
port it actually bound, and align BACKEND_PORT with it.
Data and log locations¶
| Mode | Data directory | Log directory |
|---|---|---|
| Development | backend/data |
backend/logs |
| macOS bundle | ~/Library/Application Support/erudi/backend/prod/data |
~/Library/Logs/erudi |
| Windows bundle | %LOCALAPPDATA%\erudi\backend\prod\data |
%LOCALAPPDATA%\erudi\logs |
| Linux bundle | ${XDG_DATA_HOME:-~/.local/share}/erudi/backend/prod/data |
${XDG_STATE_HOME:-~/.local/state}/erudi/logs |
backend/run.py initializes src.launcher.runtime_paths with these directories before
importing the app, so src/core/config.py and src/core/logging.py adopt them on
import. ERUDI_DATA_ROOT overrides the root.
The backend writes backend.log inside the log directory (rotating, 10 MB, 10 backups).
See Logging & Traceability for the Electron-side log and request-id
correlation.
Lifecycle events¶
Every event is one JSON object per line on stdout, with a ts field (UTC ISO-8601 with
milliseconds and a Z suffix) so it can be correlated with the Electron and backend
logs. The reader must tolerate interleaved non-JSON log lines.
{"event":"starting","arch":"...","mode":"...","data_path":"...","ts":"..."}
{"event":"phase","phase":"preparing_database","ts":"..."}
{"event":"ready","port":27182,"ts":"..."}
{"event":"shutdown","ts":"..."}
{"event":"startup_error","code":"NO_PORT_AVAILABLE","message":"...","ts":"..."}
{"event":"engine_notice","code":"CUDA_DRIVER_TOO_OLD","gpu_name":"...","ts":"..."}
Startup phases¶
phase events report startup progress so the loader can say what is happening. They are
emitted from inside the FastAPI lifespan through emit_phase
(backend/src/launcher/events.py), which run.py injects on app.state; under plain
uvicorn they are a no-op.
| Phase | Emitted before |
|---|---|
preparing_database |
starting the embedded PostgreSQL cluster (first run pays a one-time initdb) |
recovering_database |
waiting for a PostgreSQL cluster to finish WAL crash recovery after an unclean shutdown |
running_migrations |
the Alembic upgrade to head |
loading_catalog |
seeding and reconciling the model catalog from the bundled snapshot |
Phases are informational. Readiness is the ready event or a confirming health check —
never a phase.
Engine notice¶
engine_notice is emitted from the lifespan, after the migrations and the catalog, when
the selected engine is CUDA_Engine and the pre-flight finds a GPU or a driver below what
the bundled CUDA build needs. It is not a startup failure — the backend is healthy and
about to serve — so the frontend holds it until the app is past ready and shows it as a
decision, not as an error screen.
{
"event": "engine_notice",
"code": "CUDA_DRIVER_TOO_OLD",
"gpu_name": "NVIDIA GeForce GTX 1080",
"compute_capability": "6.1",
"driver_cuda_version": "12.1",
"required_cuda_version": "12.8",
"raw": "...",
"ts": "..."
}
| Code | Meaning |
|---|---|
CUDA_COMPUTE_CAPABILITY_TOO_LOW |
the card is below compute capability 5.0; no driver fixes it |
CUDA_DRIVER_TOO_OLD |
the driver's CUDA version is below what this card needs to run the bundled build |
At most one notice per boot, and none at all when a reading is unavailable. The floors and
where each comes from are in backend/src/engines/cuda_compatibility.py; the
per-generation table is in Hardware Detection.
Error codes¶
startup_error carries one of:
| Code | Meaning |
|---|---|
NO_PORT_AVAILABLE |
every port in the 27182-27199 scan is busy (transient; the frontend retries) |
CRASH_BEFORE_READY |
the server thread exited before binding a port |
PORT_TIMEOUT |
the server did not bind within the startup window |
IMPORT_ERROR |
the FastAPI application failed to import |
DATA_PREP_ERROR |
data directories could not be prepared |
UNEXPECTED_ERROR |
unhandled exception in the server thread |
POLLING_ERROR |
unhandled exception in the startup polling loop |
A non-zero process exit code also signals a startup failure.
Shutdown and orphan prevention¶
A backend that outlives its parent is a stranded process holding a port, a model, and a
database cluster. Two environment variables control the guards, both documented in
backend/.env.example:
| Variable | Effect |
|---|---|
ERUDI_WATCH_STDIN |
Set to 1 by the Electron main process: the launcher exits on stdin EOF, which is what a dead parent produces. |
ERUDI_NO_PARENT_WATCHDOG |
Set to 1 to disable the parent-process watchdog for a deliberately detached run. |
The parent watchdog otherwise polls every couple of seconds. Whichever path triggers the stop (watchdog, SIGTERM relay, stdin EOF), uvicorn's wait for in-flight requests is bounded, then the lifespan shutdown runs: the inference child is terminated and the embedded PostgreSQL cluster is stopped.
Usage¶
- Development:
cd backend && source venv/bin/activate && python run.py --port 27182 - Bundled build: PyInstaller executes the same module; no platform-specific launcher is required.
- The frontend reads stdout line by line and acts on the JSON events.