Summary
The BoostGraph shared daemon writes .boost/graph/daemon.pid when it starts and only removes it on a graceful stop. When the machine reboots (WSL2 here) the daemon dies without cleanup and the lockfile survives. The next daemon launch treats the lock as live if process.kill(pid, 0) succeeds. After a reboot PIDs restart from zero, so a low PID is almost certainly taken by some unrelated process. Result on this machine: for three days every launch in one project logged Another daemon (pid 2791) already holds the lock; exiting, no shared daemon ever came up, and every agent session silently ran the in-process fallback engine. Nothing in the session surfaces this. It cleared itself only because the boot after that happened not to reuse pid 2791.
The stale lock is the mirror image of an orphaned daemon: the process is gone, but its footprint (daemon.pid, daemon.sock, the registry record under ~/.boost/graph/daemons/) stays behind and is trusted.
Environment
Boost v0.13.7 at the time of the failure (auto-updated to v0.13.11 today), bundled BoostGraph package v1.5.0 (both versions log v1.5.0), bundled node v24.16.0. Ubuntu under WSL2, kernel 6.18.33.2-microsoft-standard-WSL2, pid_max 4194304, ext4 project directory (socket bound in-project, not the tmpdir fallback). Project: a ~65 MB index with the file watcher active.
Timeline (UTC, from .boost/graph/daemon.log and journalctl --list-boots)
| Time |
Event |
| 09-02 06:30:15 |
Boot A starts |
| 09-02 06:32:38 |
Daemon starts: Listening on .../daemon.sock (pid 2791, v1.5.0). Idle timeout 300000ms. |
| 09-03 00:50:44 |
Last line from pid 2791 (Auto-synced 31 file(s)) |
| 09-03 01:05:01 |
Boot A ends. No Shutting down line; daemon.pid keeps pid 2791 |
| 09-03 02:29:25 |
Boot B starts (runs until 09-06 04:23) |
| 09-04 15:06:31 |
Another daemon (pid 2791) already holds the lock; exiting. |
| 09-04 19:07:49 |
same |
| 09-05 04:10:46 |
same |
| 09-05 05:07:21 |
same |
| 09-06 05:40:28 |
Boot C starts |
| 09-06 22:00:42 |
Daemon starts again (pid 4091454): pid 2791 was free in this boot, so clearStaleDaemonLock removed the record and the acquire succeeded |
Pid 2791 was two minutes into Boot A. No BoostGraph process existed in Boot B (fresh kernel), so the "live" holder was whatever unrelated process received pid 2791 in that boot. The four refusals span 38 hours and four separate agent sessions, each of which fell back to the in-process engine after the spawned daemon exited.
Where it happens (lib/dist/mcp, v0.13.11 bundle)
daemon.js tryAcquireDaemonLock: the lock record is {pid, version, socketPath, startedAt}. Nothing identifies the boot or the process image.
index.js startDaemonProcess: on kind: 'taken', if (existing.pid > 0 && isProcessAlive(existing.pid)) → log and process.exit(0). isProcessAlive is process.kill(pid, 0) with EPERM treated as alive.
daemon.js clearStaleDaemonLock runs the same liveness probe, so the stale record can never be cleared while any process holds that pid.
- The proxy side never learns why the daemon exited. It polls the socket, times out, and serves in-process. The only trace is the one line in
daemon.log.
~/.boost/graph/daemons/<hash>.json has the same shape. boost graph daemon prunes records whose pid is dead, so it is exposed to the same reuse hazard in the opposite direction (a reused pid would list a phantom daemon). Per-project daemon.pid and daemon.sock are never swept: one project here still carries a pid/socket pair from 2026-08-28 because no daemon has been started there since.
Impact
- The shared daemon is unavailable for the project for as long as the reused pid stays alive. Low pids after a boot typically belong to long-lived system processes, so that is "until the next reboot", and the next reboot may collide again.
- Every session pays in-process startup and indexing, and concurrent sessions each hold their own engine on the same index. No warning reaches the agent or the user.
Suggested fixes
- Put a boot identity in the lock record and treat any record from another boot as stale:
/proc/sys/kernel/random/boot_id on Linux, kern.boottime on macOS, GetTickCount64-derived boot time on Windows. This alone would have avoided this report.
- Verify the holder is a BoostGraph daemon before believing it: on Linux compare
/proc/<pid>/cmdline (or readlink /proc/<pid>/exe) against the bundled node/boostgraph.js serve --mcp; on other platforms compare the process start time against startedAt (a reused pid always starts later than the record). The startedAt field is already there.
- When a launch is refused because a holder is "alive", say so on the proxy side (
[BoostGraph MCP] daemon lock held by pid N, serving in-process) so the fallback is visible in the session instead of only in daemon.log.
- Sweep
daemon.pid/daemon.sock on startup when the record fails the checks above, and extend boost graph unlock (or boost doctor) to cover daemon.pid, reporting a lock whose pid does not belong to a BoostGraph process.
Workaround
Delete .boost/graph/daemon.pid (and daemon.sock) in the affected project after a reboot. boost graph unlock does not help: it only removes boostgraph.lock, the index-writer lock. The next session then spawns a daemon normally.
Method
Behaviour taken from the shipped lib/dist/mcp/*.js in the v0.13.11 bundle and from the per-project daemon.log files; boot boundaries from journalctl --list-boots. The failing boot is over, so the holder of pid 2791 could not be named. No reparented (PPID 1) BoostGraph node process was present after today's boot; the detached daemon reparents to init by design and exits five minutes after its last client.
Summary
The BoostGraph shared daemon writes
.boost/graph/daemon.pidwhen it starts and only removes it on a graceful stop. When the machine reboots (WSL2 here) the daemon dies without cleanup and the lockfile survives. The next daemon launch treats the lock as live ifprocess.kill(pid, 0)succeeds. After a reboot PIDs restart from zero, so a low PID is almost certainly taken by some unrelated process. Result on this machine: for three days every launch in one project loggedAnother daemon (pid 2791) already holds the lock; exiting, no shared daemon ever came up, and every agent session silently ran the in-process fallback engine. Nothing in the session surfaces this. It cleared itself only because the boot after that happened not to reuse pid 2791.The stale lock is the mirror image of an orphaned daemon: the process is gone, but its footprint (
daemon.pid,daemon.sock, the registry record under~/.boost/graph/daemons/) stays behind and is trusted.Environment
Boost v0.13.7 at the time of the failure (auto-updated to v0.13.11 today), bundled BoostGraph package v1.5.0 (both versions log
v1.5.0), bundled node v24.16.0. Ubuntu under WSL2, kernel6.18.33.2-microsoft-standard-WSL2,pid_max4194304, ext4 project directory (socket bound in-project, not the tmpdir fallback). Project: a ~65 MB index with the file watcher active.Timeline (UTC, from
.boost/graph/daemon.logandjournalctl --list-boots)Listening on .../daemon.sock (pid 2791, v1.5.0). Idle timeout 300000ms.Auto-synced 31 file(s))Shutting downline;daemon.pidkeeps pid 2791Another daemon (pid 2791) already holds the lock; exiting.clearStaleDaemonLockremoved the record and the acquire succeededPid 2791 was two minutes into Boot A. No BoostGraph process existed in Boot B (fresh kernel), so the "live" holder was whatever unrelated process received pid 2791 in that boot. The four refusals span 38 hours and four separate agent sessions, each of which fell back to the in-process engine after the spawned daemon exited.
Where it happens (
lib/dist/mcp, v0.13.11 bundle)daemon.jstryAcquireDaemonLock: the lock record is{pid, version, socketPath, startedAt}. Nothing identifies the boot or the process image.index.jsstartDaemonProcess: onkind: 'taken',if (existing.pid > 0 && isProcessAlive(existing.pid))→ log andprocess.exit(0).isProcessAliveisprocess.kill(pid, 0)with EPERM treated as alive.daemon.jsclearStaleDaemonLockruns the same liveness probe, so the stale record can never be cleared while any process holds that pid.daemon.log.~/.boost/graph/daemons/<hash>.jsonhas the same shape.boost graph daemonprunes records whose pid is dead, so it is exposed to the same reuse hazard in the opposite direction (a reused pid would list a phantom daemon). Per-projectdaemon.pidanddaemon.sockare never swept: one project here still carries a pid/socket pair from 2026-08-28 because no daemon has been started there since.Impact
Suggested fixes
/proc/sys/kernel/random/boot_idon Linux,kern.boottimeon macOS,GetTickCount64-derived boot time on Windows. This alone would have avoided this report./proc/<pid>/cmdline(orreadlink /proc/<pid>/exe) against the bundled node/boostgraph.js serve --mcp; on other platforms compare the process start time againststartedAt(a reused pid always starts later than the record). ThestartedAtfield is already there.[BoostGraph MCP] daemon lock held by pid N, serving in-process) so the fallback is visible in the session instead of only indaemon.log.daemon.pid/daemon.sockon startup when the record fails the checks above, and extendboost graph unlock(orboost doctor) to coverdaemon.pid, reporting a lock whose pid does not belong to a BoostGraph process.Workaround
Delete
.boost/graph/daemon.pid(anddaemon.sock) in the affected project after a reboot.boost graph unlockdoes not help: it only removesboostgraph.lock, the index-writer lock. The next session then spawns a daemon normally.Method
Behaviour taken from the shipped
lib/dist/mcp/*.jsin the v0.13.11 bundle and from the per-projectdaemon.logfiles; boot boundaries fromjournalctl --list-boots. The failing boot is over, so the holder of pid 2791 could not be named. No reparented (PPID 1) BoostGraph node process was present after today's boot; the detached daemon reparents to init by design and exits five minutes after its last client.