Skip to content

fix(desktop): make embedded server start idempotent (#493) - #508

Draft
TYRMars wants to merge 1 commit into
mainfrom
claude/vibrant-dijkstra-ko1809
Draft

fix(desktop): make embedded server start idempotent (#493)#508
TYRMars wants to merge 1 commit into
mainfrom
claude/vibrant-dijkstra-ko1809

Conversation

@TYRMars

@TYRMars TYRMars commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #493. ServerManager.startEmbedded() had no "already running" guard on the embedded branch, so a successful start could be silently replaced by a second one — leaking the first generation's Fastify listener, MCP child processes, and store handles.

On macOS the app stays resident after the last window closes (window-all-closed doesn't quit on darwin). Clicking the dock icon re-enters createWindow()ensureServer()startEmbedded() while the server is still running. Because pickPort() hands back a fresh ephemeral port there's no EADDRINUSE to surface the double start, and this.app / this.mcpClients get overwritten. before-quitstop() then only ever reaps the newest generation, so every earlier server + MCP child survives until the user kills them by hand. With JARVIS_DB_URL=sqlite:… a second AppState also opens a second writer against the same DB file.

Change

Guard the embedded branch in startEmbedded():

  • If an embedded server is already running for the same workspace → log and reuse it (early return).
  • If the workspace changed out from under a running server → stop() the old one first (what restart() already does), then start fresh.

restart() still performs a full restart because it calls stop() before reaching startEmbedded(), so this.app is null there and the guard is a no-op on that path.

Testing

  • Added a regression test: a second ensureServer({ forceEmbedded: true }) after a successful embedded start keeps the same api_origin (proving no second server on a new port) and the single server stays healthy.
  • pnpm --filter @jarvis/desktop typecheck
  • node --test src/main/server-manager.test.ts — 3/3 pass ✅
  • eslint clean on both changed files ✅

Notes

Not a duplicate of #170 (start-failure leak), #317 (lifecycle mutex — these calls are sequential), or #318 (pickPort TOCTOU — the fresh port is what hides this bug). The missing piece was an idempotency check, which this adds.

🤖 Generated with Claude Code


Generated by Claude Code

`ServerManager.startEmbedded()` unconditionally built a new Fastify
server, AppState, and MCP child processes, overwriting `this.app` /
`this.mcpClients` without closing the previous set. On macOS the app
stays resident after the last window closes, so a dock re-activate
re-enters createWindow() -> ensureServer() -> startEmbedded() with the
server still running, orphaning the first generation (listener, MCP
children, store handles) — `before-quit`/stop() only ever reaps the
newest one.

Guard the embedded branch: when an embedded server is already running
for the same workspace, log and reuse it; when the workspace changed,
stop the old one first (what restart() already does) before starting
fresh. `restart()` still fully restarts because it calls stop() before
reaching here.

Adds a regression test asserting a second ensureServer() keeps the same
origin (no second server on a new ephemeral port).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjZhzgw54atKvgd9WnDunW
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants