Launcher¶
Runtime plumbing for the packaged app: writable path resolution, the embedded PostgreSQL cluster, lifecycle events and OS-specific process containment.
src.launcher
¶
Launcher support utilities.
The launcher package exposes helpers shared between the cross-platform
startup script (backend/run.py) and the rest of the backend stack. It
keeps runtime-specific state (like data and log directories) in one place
so both the launcher and the FastAPI application resolve paths
consistently.
RuntimePaths(mode, backend_root, data_dir, log_dir)
dataclass
¶
Resolved directories for the current Erudi process.
ensure_runtime_paths_initialized(backend_root=None)
¶
Ensure runtime paths are initialized, defaulting to development mode.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
backend_root
|
Optional[Path]
|
Explicit backend root directory. When omitted, defaults to
the repository layout ( |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
RuntimePaths |
RuntimePaths
|
Resolved runtime directories. |
Source code in backend/src/launcher/runtime_paths.py
get_runtime_paths()
¶
Return the current runtime paths.
Returns:
| Name | Type | Description |
|---|---|---|
RuntimePaths |
RuntimePaths
|
Current runtime path configuration. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If runtime paths were not initialized yet. |
Source code in backend/src/launcher/runtime_paths.py
initialize_runtime_paths(mode, backend_root, packaged_data_dir=None)
¶
Initialize runtime paths for the current process.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mode
|
str
|
Runtime mode, typically |
required |
backend_root
|
Path
|
Path to the backend root directory (contains |
required |
packaged_data_dir
|
Optional[Path]
|
Optional path to the read-only data payload bundled with the application. Only required for bundled builds. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
RuntimePaths |
RuntimePaths
|
Resolved directory information for the process. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If runtime paths are initialized more than once or the mode is unsupported. |
Source code in backend/src/launcher/runtime_paths.py
src.launcher.db_watchdog
¶
Embedded-Postgres watchdog: detect a dead cluster, resurrect it, expose state.
The embedded PostgreSQL cluster (src.launcher.postgres_runtime) can die
mid-session -- the whole lineage killed externally (a corporate EDR/console
cleaner reaping the shared conhost was reproduced on the packaged app, #162).
Before this module the app ran as a zombie: /erudi/health/ kept answering
{"status": "ok"} (a static reply that never touches the DB) while every
DB-backed endpoint 500ed -- 17 observed zombie minutes in the July incident.
Design (validated on #162, PR B):
- State machine:
db_statein {ok,recovering,failed}, a module global read by/health(seeget_db_state). - Reactive detection: a SQLAlchemy
handle_errorlistener on the live engine flips the state torecoveringthe instant a request hits a connection-level disconnect. The hook does NOTHING else (no I/O): it flags state and wakes the recovery loop thread-safely. - Proactive detection: while healthy the loop probes
SELECT 1every ~30s (off the event loop) so the zero-traffic case (the July incident: no DB request to trip on for 17 minutes) is still caught. - Resurrection: on
recoveringthe loop restarts the cluster through the field-provenstart_postgresmachinery (WAL recovery, the #215 cache eviction, retries) and re-binds the three DB tenants -- SQLAlchemy engine (init_database), the LangGraph checkpointer, and the KB vector store (init_kb_store) -- as a FULL re-init, because a resurrected postmaster may come back on a different port/URI (pool.dispose would keep the stale URI). Backoff ladder 5s/15s/60s; after the third failed attempt the state goesfailedand the loop parks until the next error/probe wake.
All log literals are ASCII (repo rule).
get_db_state()
¶
start_watchdog(app)
¶
Start the watchdog: attach the disconnect hook + spawn the recovery loop.
Call AFTER init_database has bound the live engine (the hook attaches to
core.db_engine) and the checkpointer is open on app.state.
Idempotent: a second call is a no-op while the loop is running.
Source code in backend/src/launcher/db_watchdog.py
stop_watchdog()
async
¶
Stop the watchdog cleanly: cancel + await the loop, detach the hook.
Source code in backend/src/launcher/db_watchdog.py
src.launcher.events
¶
Shared newline-delimited JSON lifecycle-event emitter.
Both the cross-platform launcher (backend/run.py) and the FastAPI
lifespan run in the same process and share stdout. The Electron main
process (frontend/src/main.js) parses each stdout line as JSON and
tolerates interleaved non-JSON log lines. Keeping a single emitter here means
the launcher and the app agree on the wire format, and startup progress
(phase) events can originate from inside the lifespan.
Event shapes
- {"event": "starting", ...} (run.py)
- {"event": "phase", "phase": "
"} (lifespan, via emit_phase) - {"event": "ready", "port": N} (run.py)
- {"event": "shutdown"} (run.py)
- {"event": "startup_error", "code": "...", ...} (run.py)
- {"event": "engine_notice", "code": "...", "gpu_name": "...", "compute_capability": "...", "driver_cuda_version": "...", "required_cuda_version": "...", "raw": "..."} (lifespan)
engine_notice reports a GPU the bundled CUDA build cannot drive. It is NOT a
startup failure: the backend is healthy and serving, and the frontend holds the
event until after ready to show it as a decision (run on the processor
instead, or update the driver) rather than an error screen.
Every emitted event additionally carries a ts field (UTC ISO-8601 with
milliseconds and a Z suffix, e.g. 2026-07-02T09:15:32.123Z) so the
Electron log (new Date().toISOString()) and the backend log can be
correlated on a single timeline.
emit_event(payload)
¶
Print a structured JSON lifecycle event (newline-delimited) to stdout.
A ts field (UTC ISO-8601 ms, Z) is stamped on every event; the
caller's payload is not mutated.
Source code in backend/src/launcher/events.py
emit_phase(phase)
¶
Emit a startup-progress phase the frontend can surface on the loader.
Phases are informational only — the frontend still gates readiness on the
ready event / a confirming health check, never on a phase.
Source code in backend/src/launcher/events.py
src.launcher.postgres_runtime
¶
Embedded PostgreSQL runtime (pgserver) lifecycle for Erudi.
Starts/stops the local cluster bundled by the pgserver wheel (no Docker, no
system install), ensures the erudi database and the pgvector extension
exist, and derives the two URL forms consumed by the stack:
sqlalchemy_url—postgresql+psycopg://…for the sync SQLAlchemy engine (business layer) and langchain-postgresPGEngine.psycopg_url—postgresql://…forAsyncPostgresSaverand raw psycopg connections.
pgserver defaults to a Unix-domain-socket URI (…?host=<socket dir>) on
POSIX; get_server is idempotent (initdb on first run, refcounted across
processes) and registers an atexit cleanup. We still stop the cluster
explicitly from the FastAPI lifespan shutdown for a deterministic order
(checkpointer first, cluster last).
Authentication (#462). pgserver runs initdb --auth=trust --auth-local=trust
and, on Windows, where there are no Unix sockets, starts the postmaster on a
loopback TCP port -- so any process under the user's account could open the
database with no credential. Every start_postgres therefore generates a
random password once per cluster (<data_dir>/erudi_db_password), sets it
on the postgres role, rewrites pg_hba.conf so every host rule
requires scram-sha-256 (local socket rules stay trust: filesystem
permissions guard the socket), reloads the postmaster, and carries the
password in both derived URLs. The same code runs on every platform so the
POSIX test suites exercise it too; only the TCP refusal itself is Windows-only.
PostgresHandle(server, data_dir, psycopg_url, sqlalchemy_url)
dataclass
¶
Live embedded-cluster handle with the two derived connection URLs.
harden_pg_hba(text)
¶
Rewrite pg_hba.conf so every host rule authenticates with SCRAM.
Pure text -> text: trust on a host* line becomes scram-sha-256;
local (Unix socket) rules, comments, blank lines, spacing and every
other method are left byte-for-byte as they were. Idempotent.
Source code in backend/src/launcher/postgres_runtime.py
postmaster_log_tail(data_dir, max_lines=POSTMASTER_LOG_TAIL_LINES)
¶
The embedded postmaster's own last lines, or "" when it wrote none.
Reads a bounded window from the end of the file (the log is unbounded), drops the partial line that window starts on, and redacts any password literal PostgreSQL echoed from a failing statement.
Decoded defensively: Postgres writes its messages in the server encoding and in the operating system's language, so a byte that is not UTF-8 must cost a character, never the whole diagnostic. Never raises -- an absent or unreadable file simply has nothing to say.
Source code in backend/src/launcher/postgres_runtime.py
start_postgres(data_dir)
¶
Boot (or join) the embedded cluster and make the erudi DB ready.
Idempotent: safe to call on an already-initialized data dir or while the cluster is already running (pgserver refcounts users of the data dir).
Every failure below -- the postmaster that never came up, an admin
connection refused, a database or an extension that could not be created
-- leaves with the postmaster's own last lines attached
(:func:_attach_postmaster_log), because the reason is in the server's
log and nowhere else.
Source code in backend/src/launcher/postgres_runtime.py
stop_postgres(handle)
¶
Stop the embedded cluster explicitly (deterministic shutdown order).
Source code in backend/src/launcher/postgres_runtime.py
src.launcher.runtime_paths
¶
Runtime path management for launcher and backend modules.
This module centralizes the logic that determines where the Erudi backend
should read and write its runtime artifacts (embedded PostgreSQL data dir,
log files, cached models). The launcher initializes this state before
FastAPI imports occur, but a development fallback keeps local uvicorn
workflows working without the launcher.
ERUDI_DATA_ROOT (optional env var) points the whole instance -- data dir
AND log dir -- at an explicit root instead of the default dev/prod layout.
It exists for isolated side-by-side instances (integration tests booting a
throwaway backend, QA runs next to a live app) that must not share the
default embedded-Postgres cluster or append to the same log files.
RuntimePaths(mode, backend_root, data_dir, log_dir)
dataclass
¶
Resolved directories for the current Erudi process.
ensure_runtime_paths_initialized(backend_root=None)
¶
Ensure runtime paths are initialized, defaulting to development mode.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
backend_root
|
Optional[Path]
|
Explicit backend root directory. When omitted, defaults to
the repository layout ( |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
RuntimePaths |
RuntimePaths
|
Resolved runtime directories. |
Source code in backend/src/launcher/runtime_paths.py
get_runtime_paths()
¶
Return the current runtime paths.
Returns:
| Name | Type | Description |
|---|---|---|
RuntimePaths |
RuntimePaths
|
Current runtime path configuration. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If runtime paths were not initialized yet. |
Source code in backend/src/launcher/runtime_paths.py
initialize_runtime_paths(mode, backend_root, packaged_data_dir=None)
¶
Initialize runtime paths for the current process.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mode
|
str
|
Runtime mode, typically |
required |
backend_root
|
Path
|
Path to the backend root directory (contains |
required |
packaged_data_dir
|
Optional[Path]
|
Optional path to the read-only data payload bundled with the application. Only required for bundled builds. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
RuntimePaths |
RuntimePaths
|
Resolved directory information for the process. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If runtime paths are initialized more than once or the mode is unsupported. |
Source code in backend/src/launcher/runtime_paths.py
src.launcher.windows_job
¶
Windows Job Object binding child-process lifetime to this process (#341).
The parent-death watchdog (#224) and the stdin-EOF watcher both detect a dead Electron parent from WITHIN this process and then run the FastAPI lifespan shutdown, which stops the embedded Postgres cluster deterministically. Both depend on this process staying alive long enough to notice and react.
That assumption breaks when the backend itself is torn down at the same time as Electron: recent Electron versions place their spawned child processes (this backend included) inside a Windows Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so a hard-killed Electron main process cascades an OS-level TerminateProcess onto this process within milliseconds -- faster than the 0.25s/2s poll intervals of either watchdog. Postgres and llama-server, spawned as OUR children via plain subprocess.Popen (and, for Postgres, deliberately given their own console by the #162 fix so a dead CONSOLE alone never takes them down), are not members of that job and have no mechanism at all tying their lifetime to ours in this scenario -- so they survive orphaned indefinitely, holding the data directory and the port.
The fix mirrors the same trick one level down: create OUR OWN job with KILL_ON_JOB_CLOSE, assign this process to it, and keep the handle open for the life of the process. Every child this process spawns afterwards (Postgres and its ~10 forked helpers, llama-server) automatically joins the same job unless it explicitly requests CREATE_BREAKAWAY_FROM_JOB -- nothing in this codebase does, so no other file needs to change. Windows nests job objects natively (8+/Server 2012+), so this composes cleanly whether or not Electron's own job already contains this process: whenever THIS process's last handle closes -- clean exit, watchdog-triggered shutdown, or an external TerminateProcess -- the kernel tears down every member of our job too, with no Python code required to run at all. This is deliberately independent of (and a safety net under) the graceful stop_postgres() shutdown path: that path still runs first when there's time for it; this one covers the case where there isn't.
No-op on non-Windows platforms and swallowed on any failure: the app must keep working even where job objects are unavailable or restricted (e.g. under some sandboxes) -- it just loses this extra layer of orphan protection.
bind_children_to_this_process()
¶
Create a kill-on-close Job Object and assign this process to it.
Idempotent-ish: safe to call more than once (a second call just creates and leaks a second job doing the same thing), but callers should only call it once, early at startup and before spawning Postgres/llama-server, so every child inherits membership from the start.
Returns True if the job was created and assigned, False if anything about it failed (already logged) -- the caller never needs to branch on this, it exists for tests.