The reload pipeline (mental model)
task dev runs two processes via concurrently:
- Electron (
pnpm run start) — main process starts the Go sidecar frombin/<os>-<arch>/relay-backend, parses the handshake JSON line, and exposes{port, token, baseUrl}to the renderer via preload. - Watcher (
node scripts/dev-watcher.mjs) — chokidar watches*.go/go.mod/go.sum, debounces 300ms, then runsopenapi → codegen → build:goand writes.dev-reload(content: epoch ms).
The Electron main process fs.watches the parent directory, filters by filename .dev-reload, dedups by mtimeMs, then:
- Kills the old sidecar.
- Spawns a new one (new port, new token).
- Calls
reloadIgnoringCache()on all windows. - Renderer's
getClient()cache is invalidated by the reload.
Checklist (run in order)
Is the watcher actually running?
task devshould show two log streams (electronblue,watchermagenta). If onlyelectronis logging,concurrentlykilled the watcher — look at terminal for an error. Common cause: chokidar v4+ failure because someone passed a glob instead of a directory.Does the watcher see your edit? The watcher logs each detected change. Save a Go file. If nothing logs:
- Confirm the file is under a watched directory (not under
node_modules,.git,bin/, etc.). - On Linux check
cat /proc/sys/fs/inotify/max_user_watches— exhausted watches silently drop events. Raise it if needed. - Confirm
scripts/dev-watcher.mjsexcludes.dev-reloaditself; if it doesn't, the watcher self-loops and may throttle.
- Confirm the file is under a watched directory (not under
Did the watcher rebuild? After a detected change you should see
openapi → codegen → build:gorun in sequence. Ifbuild:gofails, the binary atbin/<os>-<arch>/relay-backendis not replaced — the next sidecar respawn would just run the old binary. Fix the compile error first.Was
.dev-reloadupdated?stat -c '%y %n' .dev-reloadThe mtime should match your last save. If not, the watcher's write step failed.
Did Electron react? The main process logs the respawn. If
.dev-reloadmtime advanced but no respawn:- The
fs.watchmay be filtering by a filename that doesn't match (Windows rename+change emits two events with different names — confirm the main process filter accepts both). - The
mtimeMsdedup may treat two fast saves as one; that's intended, not a bug.
- The
Did the renderer reconnect? New sidecar = new port and new token. Each window should
reloadIgnoringCache()automatically. If the renderer logs401 X-Relay-Tokenit's still holding the old token — confirmgetClient()is not cached in module scope above thereloadboundary. The current implementation inelectron/src/api/client.tscaches inside the module; the renderer reload clears it.
Platform-specific pitfalls
- Windows:
process.kill(pid, "SIGUSR2")is a no-op. Reload is intentionally driven by the.dev-reloadtrigger file, not signals — keep it that way. - Linux: inotify watch limits silently drop events at scale. See step 2.
- macOS: FSEvents coalesces fast saves; the debounce in the watcher already accounts for that. Don't lower the debounce below ~300ms.
Things that look like bugs but aren't
- UI state lost on reload: expected. Go has no hot code replacement; the sidecar is fully restarted and the renderer is fully reloaded. TS HMR (Forge's Vite) is a separate path that handles renderer-only edits without sidecar restart.
schema.tschanged but no reload happened: that's the renderer side. Vite HMR handles it without touching the sidecar.- Two reloads back-to-back after a single save: usually atomic-write editors (vim's
:w) or Windows rename+change. The main process dedups bymtimeMs; if you still see doubles, your editor wrote twice with different mtimes.
Quick reset
If state seems wedged:
task clean:dev # rm .dev-reload
# Ctrl-C task dev
task dev
If the binary is suspect:
rm -rf bin
task build:go
task dev