Skip to content

claude-smart dashboard start() loses EADDRINUSE race and exits without retry; any caller with a different PLUGIN_ROOT triggers it #111

Description

@wenchanghan

Summary

dashboard-service.sh start reaps any claude-smart dashboard on port 3001 whose x-claude-smart-plugin-root header doesn't match the caller's PLUGIN_ROOT, then tries to bind a new one. When two callers race in the same second — common during multi-host installs, multi-host SessionStart hooks, and pytest e2e test runs — the loser hits EADDRINUSE, the script exits, and nothing schedules a retry. The dashboard is then dead until a user runs dashboard-service.sh start manually.

The bug is broader than the install path: any process that calls dashboard-service.sh start with a different PLUGIN_ROOT triggers the same race. The five callers observed in the wild:

  1. npx claude-smart install --host X (one per host)
  2. npx claude-smart update --host X (one per host)
  3. Each host's SessionStart hook (Claude Code, Codex, OpenCode — each plugin dir registers independently)
  4. Pytest e2e tests with a fixture PLUGIN_ROOT (writes to its own log under /private/var/folders/.../home/.claude-smart/, so the reap is invisible from ~/.claude-smart/dashboard.log)
  5. Stale leftover plugin caches (e.g., the 0.2.45 Claude Code cache from a prior install was on disk and held its own dashboard-service.sh — workaround is rm -rf ~/.claude/plugins/cache/reflexioai/claude-smart/<old-version>)

Reproduction (deterministic on this machine, but the underlying issue is cross-platform)

Trigger A: multi-host install back-to-back

npx -y claude-smart@0.2.49 install --host claude-code
npx -y claude-smart@0.2.49 install --host codex
npx -y claude-smart@0.2.49 install --host opencode

Each install writes its own plugin copy to a separate directory, then ends with dashboard-service.sh start against the just-installed PLUGIN_ROOT. The four PLUGIN_ROOT paths on this machine were:

  • /Users/derrickhan/.npm/_npx/66a6b70155dfac7e/node_modules/claude-smart (npx cache for the claude-code install)
  • /Users/derrickhan/.claude/plugins/cache/reflexioai/claude-smart/0.2.49 (Claude Code cache)
  • /Users/derrickhan/.codex/plugins/cache/reflexioai/claude-smart/0.2.49 (codex cache)
  • /Users/derrickhan/.claude-smart/opencode/claude-smart/plugin (opencode local package)

Trigger B: SessionStart hooks firing in the same second

The hook.log shows three SessionStart events at 23:51:20 PDT (one claude-code resume, two opencode) within ~57 seconds. Each host's SessionStart hook calls dashboard-service.sh start with its own PLUGIN_ROOT. The same race that the install path triggers also fires from the hooks alone — no install required. The hook call is the same code path as the install tail-end, so the symptoms are identical.

Trigger C: pytest e2e test against a fixture PLUGIN_ROOT

A pytest e2e test was running with a temp install path (/private/var/folders/.../home/.claude-smart/opencode/claude-smart/plugin). The test calls dashboard-service.sh start with the temp PLUGIN_ROOT, the script reaps the real running dashboard, and the test takes over port 3001. The test's reap is invisible from ~/.claude-smart/dashboard.log because the test writes to its own dashboard.log under the temp dir.

Observed dashboard log (verbatim, Trigger A)

~/.claude-smart/dashboard.log shows nine dashboard starts in roughly four minutes, each switching PLUGIN_ROOT. The middle attempts "succeed" (Next.js reports Ready in Nms) but the script's marker_responds reap keeps killing them because their x-claude-smart-plugin-root header doesn't match the install's PLUGIN_ROOT. The last attempt loses the bind:

> next start -p 3001 -H 127.0.0.1
▲ Next.js 16.2.6
- Local:    http://127.0.0.1:3001
- Network:  http://127.0.0.1:3001
✓ Ready in 69ms

[claude-smart] dashboard: stale claude-smart dashboard on port 3001; restarting from
  /Users/derrickhan/.npm/_npx/66a6b70155dfac7e/node_modules/claude-smart/plugin

✓ Ready in 95ms
[claude-smart] dashboard: stale claude-smart dashboard on port 3001; restarting from
  /Users/derrickhan/.claude/plugins/cache/reflexioai/claude-smart/0.2.49

✓ Ready in 89ms
[claude-smart] dashboard: stale claude-smart dashboard on port 3001; restarting from
  /Users/derrickhan/.claude/plugins/cache/reflexioai/claude-smart/0.2.49

✓ Ready in 131ms
[claude-smart] dashboard: stale claude-smart dashboard on port 3001; restarting from
  /Users/derrickhan/.claude-smart/opencode/claude-smart/plugin
[claude-smart] dashboard: stale claude-smart dashboard on port 3001; restarting from
  /Users/derrickhan/.claude-smart/opencode/claude-smart/plugin

✓ Ready in 130ms
⨯ Failed to start server
Error: listen EADDRINUSE: address already in use 127.0.0.1:3001
  at <unknown> (Error: listen EADDRINUSE: address already in use 127.0.0.1:3001) {
  code: 'EADDRINUSE', errno: -48, syscall: 'listen', address: '127.0.0.1', port: 3001
}

After the last line, ~/.claude-smart/dashboard.pid still points at a process that no longer exists, port 3001 is free, and dashboard-service.sh start is never re-invoked by the hook or the install script. The dashboard is dead.

Root cause

plugin/scripts/dashboard-service.sh start case (lines 173–204) is a "check then spawn" pattern with no PID-file lock and no EADDRINUSE retry. The relevant flow:

if is_our_dashboard_running; then ... exit 0; fi      # line 184
if marker_responds; then
  stop_dashboard_listener                              # line 187 — reap
fi
if port_occupied; then ...; exit 0; fi                 # line 189 — skip
...                                                   # line 230ish — spawn next start

Two compounding gaps:

  1. start has no EADDRINUSE retry. When the next start spawn at the bottom hits EADDRINUSE (because the reap above didn't fully release the port, or because two callers raced), start exits and never schedules a retry. The dashboard stays down until a user runs start manually.

  2. is_our_dashboard_running is keyed on PLUGIN_ROOT identity (dashboard-service.sh:142dashboard_matches_current_root at line 116). Any caller with a different PLUGIN_ROOT sees the running dashboard as "not ours", drops into the reap path at line 187, and kills it. This is correct for a single-user single-install world but wrong for the actual world: the same user can have multiple plugin paths active simultaneously, and any one of them will reap the others.

backend-service.sh has the same shape (is_our_backend_running at line 148, reap_port_listeners at line 176) but in this repro it didn't crash — the reflexio CLI's services start is naturally more idempotent because the new uvicorn exits cleanly when its reflexio peer is already running, and the pid file + log are in ~/.claude-smart/ so the script does converge. The dashboard doesn't have that luck.

Suggested fix direction

The minimum fix that covers all five callers is a PID-file flock around the check-spawn window so two start invocations serialize:

exec 9>"$PID_FILE.lock"
flock -n 9 || { emit_ok; exit 0; }   # someone else is starting; that's fine
... existing is_our_dashboard_running / stop_dashboard_listener / port_occupied / spawn ...

This works because:

Smaller follow-up that helps but doesn't close the bug:

Workaround applied (not a fix)

rm -rf ~/.claude/plugins/cache/reflexioai/claude-smart/0.2.45

This removes one stale leftover plugin cache so its dashboard-service.sh is no longer callable. Reduces the surface area but other PLUGIN_ROOT callers remain.

Environment

  • claude-smart 0.2.49 (just released)
  • macOS (darwin), Apple Silicon
  • Node v22.22.2, uv-managed Python 3.12 venv
  • Reproduced on a single user account with all three hosts installed in sequence

Cross-references

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions