Processes & IPC¶
Three processes, three boundaries:
Renderer (src/renderer/)¶
React + Vite, no Node access. Talks to:
- Python over HTTP to
127.0.0.1:<backend-port>/api/* - The OS via
window.electronAPI(exposed by the preload script)
The port is dynamic per app instance — the renderer does not hardcode it.
initBackendUrl() in src/renderer/utils/backendApi.ts runs from
src/renderer/main.tsx before the first render: it fetches the real port from
the main process over the backend.getInfo IPC and caches it, so the
synchronous getBackendUrl() callers get the right base URL. To point at a
different backend, set PHYTOGRAPH_BACKEND_PORT rather than editing code.
Main (src/main/)¶
Electron lifecycle, written as ESM. Responsibilities:
| File | Responsibility |
|---|---|
main.ts |
BrowserWindow creation, app lifecycle |
backend.ts |
Spawns and supervises the Python sidecar |
ipc.ts |
ipcMain handlers for dialog, fs, persistent store, logs |
updater.ts |
electron-updater wiring |
logger.ts |
Central electron-log config; unified session log file |
Backend (backend-api/main.py)¶
A single ~23,000-line FastAPI file containing all endpoints:
/api/fit, /api/triangulate, /api/plant/*, /api/c2m/*,
/api/skeleton/extract, and more.
backend_wrapper.py is the PyInstaller entrypoint.
Grep for routes
When grepping for routes, use ^@app\. to find them quickly in
backend-api/main.py.
The IPC bridge is intentionally narrow¶
Only these surfaces are exposed to the renderer:
dialog(open/save file dialogs)fs(filesystem ops the renderer can't do over HTTP — restricted to user-selected paths, see Filesystem access below)store(persistent settings viaelectron-store)backend.getInfo(version + port reporting)shell.openExternal(open https/mailto URLs)logs(forward renderer logs, get the log path, export a combined log file)webUtils.getPathForFile(drag-and-drop path resolution)onBackendStatus(one-way main → renderer push of backend crash/restart status)onUpdaterStatus(one-way main → renderer push of auto-update download progress, rendered as aStatusPill)onOpenFiles/notifyRendererReady(OS "Open With" / file-association support — main pushes the paths the OS handed Phytograph; the renderer acknowledges readiness so main can flush paths queued during cold start)
Anything compute-heavy goes over HTTP, not IPC. This keeps the Electron main process responsive and lets the backend be developed and tested as a normal HTTP server.
Backend supervision & recovery¶
backend.ts doesn't just spawn the sidecar — it keeps it alive:
- Crash → respawn. If the Python process dies and we didn't ask it to
(a native open3d/PyHelios crash, an OOM kill), the supervisor respawns it
on the same port with a capped backoff (3 attempts: 500 ms → 2 s → 5 s).
Spawn failures take the same path — both the async
'error'event (EACCES, missing dyld) and a synchronousspawn()throw (EBADARCH-86, a wrong-architecture binary) — so a backend that can't even start still ends in thefaileddialog rather than a windowless, dialogless app. The same port matters because the renderer fetches the backend URL once at startup (initBackendUrl) and never re-fetches it. On a healthy respawn the attempt counter resets; on exhaustion the supervisor gives up. - Status push. Each transition is pushed to the renderer over the
backend:statuschannel (restarting/ready/failed).App.tsxtoasts these — notably, after areadyit tells the user to re-import, because the sidecar holds imported clouds and plant sessions in RAM and a crash loses them. - Clean shutdown.
stopBackend()sends SIGTERM, then escalates to SIGKILL after 3 s if the process is still alive, so a sidecar stuck in a long native call can't orphan and hold its port/RAM.
In-RAM session eviction¶
A cloud session (CloudSession) or plant session (PlantSession) is the
source of truth in RAM — a cloud session is ~30–60 bytes/point; a plant
session pins a live PyHelios context. They were previously reclaimed only by
an explicit DELETE, so a renderer reload/crash that never issued one leaked
them until the backend died.
backend-api/main.py now bounds them lazily (no background thread), mirroring
the LRU-by-timestamp policy of the on-disk _evict_octree_cache:
- Every session read/mutate bumps
last_accessed; every create opportunistically sweeps (_sweep_cloud_sessions/_sweep_plant_sessions). - A sweep drops sessions idle past
PHYTOGRAPH_SESSION_IDLE_TTL_SECONDS(default 30 min), then evicts least-recently-accessed survivors down toPHYTOGRAPH_MAX_CLOUD_SESSIONS/PHYTOGRAPH_MAX_PLANT_SESSIONS(default 8 each). Evicted plant sessions get their PyHelios context torn down. - The per-session undo stack (
deleted_history, one full point-mask per erase) is capped atPHYTOGRAPH_MAX_DELETED_HISTORY(default 50) snapshots.
All four limits are environment-overridable.
Filesystem access (allowlist)¶
The fs bridge enforces "user-selected paths only" — it isn't a blanket
filesystem API. src/main/fsAllowlist.ts records every path the user actually
chose and the handlers reject anything else, so a renderer compromise can't read
~/.ssh/id_rsa or overwrite arbitrary files:
- An open/save dialog result is allowlisted when it's returned.
- A drag-drop /
<input type=file>path is allowlisted by preload right afterwebUtils.getPathForFileresolves it (one-wayfs:allowPathIPC). - Reads are permitted for an explicitly-selected file and its direct
siblings (companion-file allowance — selecting
scene.xmllets the scan-import resolver findscene.xyznext to it). Writes are permitted to a save target and to direct children of a chosen export directory. Neither recurses into subdirectories.
Custom protocols & data transport¶
Heavy data never crosses IPC as JSON:
- Octree streaming.
octreeProtocol.tsserves Potree files overapp://octree/<sha1>/<file>.octree.binis hundreds of MB and potree-core fetches it inRangechunks, so the handler streams each range withfs.createReadStream→ a webReadableStreamrather than reading it into a Buffer — no main-process memory spike, no event-loop block.metadata.jsonstays a small buffered read (it needs an inf/nan→null rewrite).- One frame update for every octree. potree's point budget and its node
LRU are both global to the shared
Potreemanager, soupdatePointCloudsmust be called once per frame with the full array of visible octrees — never once per cloud. Cloud components (OctreePointCloud,MissOctree) register with the frame registry inviewer/potreeManager.ts; the singlePotreeFrameDriverinside the Canvas drives them together. Updating clouds individually makes each one claim the entire budget (N× the intended resident points) and makes each call'slru.freeMemory()evict whichever cloud was touched least recently — so with several scans loaded the clouds visibly flicker in and out every frame, worst during crop preview where the reduced budget puts demand above the2 × pointBudgeteviction threshold.
- One frame update for every octree. potree's point budget and its node
LRU are both global to the shared
- Binary point-cloud frames. Point-cloud import and compute responses use a
packed binary layout (
PHX1for import via_pack_pointcloud_response;PHB1for array responses) decoded straight intoFloat32Arrayviews, bypassing V8's ~512 MB max-string ceiling. LAS/LAZ import (bothimport_by_pathand the no-path multipart fallback) and text export (_format_points_as_text, now vectorised vianp.savetxt) all go through these fast paths instead of per-point JSON / f-string loops.
Compute caps¶
Several backend endpoints fail fast past a point cap instead of hanging on a
pathological cloud — _TREEISO_MAX_POINTS, _WOOD_SEGMENT_MAX_POINTS, and
_SKELETON_MAX_POINTS (PHYTOGRAPH_SKELETON_MAX_POINTS, default 3 M; the
skeleton neighbour graph is built with a single batched KD-tree query rather
than a per-point Python loop). All are environment-overridable.
Logging¶
All three processes feed one log file per app session owned by the main
process via electron-log
(configured in src/main/logger.ts):
| OS | Log directory |
|---|---|
| macOS | ~/Library/Logs/Phytograph/ |
| Windows | %APPDATA%\Phytograph\logs\ |
| Linux | ~/.config/Phytograph/logs/ |
Each launch writes to its own main-<timestamp>-pid<n>.log (via the file
transport's resolvePathFn), so a bug report carries just that session instead
of weeks of interleaved runs. On startup initLogging() prunes all but the 10
newest sessions (skipped under E2E, where many app instances share the dir).
A single very long session still rotates at 5 MB into ….old.log.
Each line is scope-tagged by origin:
[main]— main-processconsole.*(patched onto the file transport inlogger.ts), plus anuncaughtException/unhandledRejectionhandler.[backend]— the Python sidecar's stdout/stderr, teed line-by-line into the file bybackend.ts(the passthrough to the terminal is kept too). The backend also writes its ownphytograph-backend-<session>.login the same directory (backend_wrapper.pyadds aRotatingFileHandleratPHYTOGRAPH_LOG_DIR, whichmain.tssets to the log dir; the<session>tag comes fromPHYTOGRAPH_LOG_SESSION, matching the main file's tag so the pair is exported together).main.pyregisters an@app.exception_handlerthat logs unhandled 500s structurally.[renderer]—console.error/console.warnandErrorBoundarycatches, forwarded over thelog:writeIPC channel (src/renderer/lib/logger.ts).[updater]— auto-update events.
The feedback dialog's Attach session logs option calls logs:export, which
assembles the electron-log file + the backend's own file into one text file the
user saves and drags into a bug report (copySessionLogTo in logger.ts).
Port wiring¶
Ports are chosen at runtime, not fixed. The constants in
src/shared/constants.ts are only fallback defaults for a bare
electron . or a standalone backend_wrapper.py launch:
| Constant | Fallback |
|---|---|
RENDERER_DEV_PORT (Vite dev server) |
1427 |
BACKEND_PORT_PROD (backend) |
8008 |
Whoever owns the instance picks the real port, so concurrent app instances,
a npm run dev session, and parallel E2E runs never collide:
npm run dev—scripts/dev.mjscallsfindFreePort()(bind:0) for both the backend and Vite, passes the backend port touvicorn --portand to Electron viaPHYTOGRAPH_BACKEND_PORT, and the renderer port viaPHYTOGRAPH_RENDERER_PORT. It also setsPHYTOGRAPH_DEV_BACKEND=1, which makes the Electron supervisor stand down instead of spawning its own bundle — so in dev, uvicorn serves requests, not the PyInstaller sidecar.- Packaged app —
resolvePort()insrc/main/backend.tspicks a free port (or honorsPHYTOGRAPH_BACKEND_PORTif pinned) and spawns the bundled backend with it. - E2E —
tests/e2e/helpers/launchApp.tspicks a free port per launch and pins it viaPHYTOGRAPH_BACKEND_PORT.
The renderer learns the port over the backend.getInfo IPC (see above), which
returns getBackendPort() from the main process.