docs: capture engine singleton-lock learning (EngineAlreadyRunningError != no engine)

Document the false "engine not running" banner root cause and fix as a
docs/solutions learning, and add an "Engine Singleton Lock" entry to
CONCEPTS.md: a failed per-machine lock acquisition is proof an engine is
running elsewhere, not "no engine."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-21 03:15:22 -07:00
parent 7635ba8682
commit 7d312b416d
2 changed files with 122 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
---
title: "EngineAlreadyRunningError means an engine IS running, not 'no engine'"
date: "2026-06-21"
category: integration-issues
module: "packages/engine project-engine-manager + packages/dashboard server health"
problem_type: integration_issue
component: engine_runtime
symptoms:
- "Dashboard shows a persistent 'engine not running' banner even though an engine is running"
- "Logs repeat 'Refusing to start engine for <projectId>: Another engine is already running...' every 30s reconciliation tick"
- "Reconciliation tries to start engines for N projects, then errors because each is already running"
root_cause: incorrect_state_derivation
resolution_type: code_fix
severity: medium
related_components:
- engine
- dashboard
- development_workflow
tags:
- engine-singleton-lock
- multi-process
- reconciliation
- dashboard-health
- regression
related_prs:
- "https://github.com/Runfusion/Fusion/pull/1704"
---
## Problem
Starting a second fusion process (e.g. `pnpm dev dashboard --no-auth` while an installed `pnpm fusion` is already running) showed a false **"engine not running"** banner — even though an engine was live on the machine for every project.
## Symptoms
- Banner visible despite a running engine.
- Repeating log line every reconciliation tick: `Refusing to start engine for <projectId>: Another engine is already running for project <projectId> on this machine (blocked by socket)`.
- The collision affects every registered project (the log "tries to start engines for two projects then refuses" maps 1:1 to the live `fusion-engine-<hash>.sock` files under `$TMPDIR`).
## What Didn't Work
- Looking only at the dashboard frontend / banner component — the banner faithfully renders `dashboardHealth.engine.available === false`; the wrong value comes from the server.
- Treating it as a warm-up race (engines still starting in the background). The banner is *permanent*, not transient: reconciliation retries forever and keeps refusing, because the lock is held by a **different process** that never exits.
## Root Cause
The engine uses a **per-machine singleton lock** (`packages/engine/src/engine-singleton-lock.ts`): a lockfile under `<workingDir>/.fusion/engine.lock` plus a loopback socket `fusion-engine-<sha1(projectId)>.sock`. Only one fusion process may own the engine for a given project. When a second process launches, `acquireEngineSingleton` correctly throws `EngineAlreadyRunningError` — **positive proof an engine is live** for that project.
But that proof was discarded. In `ProjectEngineManager.createAndStart` the catch logged "Refusing to start" and rethrew **without recording that an engine exists**. The dashboard's health check `hasDashboardEngine` (`packages/dashboard/src/server.ts`) then only counted engines *this* process owns:
```ts
// before
function hasDashboardEngine(options?: ServerOptions): boolean {
const engines = options?.engineManager?.getAllEngines?.();
return Boolean(options?.engine || (engines && engines.size > 0));
}
```
So `getAllEngines()` was empty → `/api/health` reported `engine.available: false` → banner. Three views of "is an engine running" disagreed: the singleton lock (machine truth: *yes*), reconciliation's `has()` (this-process-owns: *no*), and the banner (this-process-owns: *no*).
Surfaced by `feat: start engines by default` (#1699), which made the dashboard actively attempt engine startup instead of running UI-only.
## Solution
Track engines owned by **another** process and report them as available, while still retrying so this process can take over if the other dies.
```ts
// ProjectEngineManager
private externalEngines = new Set<string>();
hasRunningEngine(): boolean {
return this.engines.size > 0 || this.externalEngines.size > 0;
}
// in createAndStart's acquireEngineSingleton catch:
if (err instanceof EngineAlreadyRunningError) {
if (!this.externalEngines.has(projectId)) { // log once, not every tick
runtimeLog.warn(`Refusing to start engine for ${projectId}: ${err.message}`);
}
this.externalEngines.add(projectId);
}
throw err;
// on successful (own) start: this.externalEngines.delete(projectId);
// in stopAll(): this.externalEngines.clear();
```
```ts
// dashboard server.ts — consult machine-level truth, fall back for old managers/test doubles
function hasDashboardEngine(options?: ServerOptions): boolean {
if (options?.engine) return true;
const manager = options?.engineManager;
if (!manager) return false;
if (typeof manager.hasRunningEngine === "function") return manager.hasRunningEngine();
const engines = manager.getAllEngines?.();
return Boolean(engines && engines.size > 0);
}
```
Reconciliation deliberately keeps retrying (it does NOT treat external engines as `has()`), so when the lock-holder exits this process acquires the lock and clears the external marker — verified live: killing the holder let the dev dashboard immediately adopt the socket.
## Why This Works
`EngineAlreadyRunningError` is the *only* signal that distinguishes "no engine anywhere" from "an engine runs in another process." Capturing it makes the dashboard report machine-level reality instead of process-local ownership. Automation still runs because the owning process's engine polls the shared DB; the second process is a correct UI-over-the-same-store.
## Prevention
- **Treat lock-acquisition failure as evidence, not an error to swallow.** A failed mutual-exclusion acquire (lock/socket/PID) usually proves the guarded resource *is* alive elsewhere — don't let downstream "is it running?" checks read process-local state instead of the lock's machine-level truth.
- Tests added (had zero prior coverage — they assumed the manager always owns what it starts):
- `packages/engine/src/__tests__/project-engine-manager.test.ts`: reports a running engine when the lock is held elsewhere; logs the refusal once across repeated attempts; takes over and clears the marker once the lock frees.
- `packages/dashboard/src/__tests__/server.test.ts`: `/api/health` reports `available: true` when another process owns the engine.
### Operational gotcha
`pnpm fusion` launches the full dashboard + engine and **never exits**. Piping it (e.g. `pnpm fusion 2>&1 | grep -i auth` to inspect auth output) leaves the server running in the background, orphaned, holding the engine singleton sockets indefinitely. To find/clear a stray holder:
```bash
lsof "$TMPDIR"/fusion-engine-*.sock # PID owning the engine locks
kill <pid> # releases the locks; reconciliation in any waiting process takes over
```