openclaw - 💡(How to fix) Fix Gateway: redundant per-plugin dep-resolution saturates event loop in embedded-fallback path [1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#75803Fetched 2026-05-02 05:29:49
View on GitHub
Comments
1
Participants
2
Timeline
6
Reactions
2
Timeline (top)
mentioned ×2subscribed ×2closed ×1commented ×1
  • Every openclaw agent --local (embedded-fallback) invocation triggers a full bundled-runtime-dep resolution pass for every loaded plugin — 33 passes against an identical 41-spec list per run.
  • All 33 plugins share the same spec list (~/.openclaw/plugin-runtime-deps/openclaw-<version>/); no per-plugin subset diffing occurs. The deps are already installed on disk — the work is pure re-verification with no actual installation.
  • Each pass takes 5–13 ms synchronously on the event loop. At 33 plugins × up to 46 embedded runs per minute, the loop never clears between requests.
  • Observed peak: eventLoopUtilization=0.997, eventLoopDelayP99=47,882 ms, runtime-plugins stage consuming 21,860 ms of a single embedded run's 49,248 ms startup time.
  • The fix is a run-scoped Map<specHash, ResolvedSet> allocated once per registry-load call and checked inside the installDeps callback before installBundledRuntimeDeps is invoked. First plugin resolves; the remaining 32 get a cache hit and skip the span entirely.

Root Cause

File (dist): dist/loader-CLyHx60E.js, lines 4078–4128 File (source): src/plugins/loader.ts Function: prepareBundledPluginRuntimeLoadRoot Inner callback: installDeps

The installDeps callback is invoked once per plugin during the registry-load for-loop. Each invocation runs measureDiagnosticsTimelineSpanSync("runtimeDeps.stage", ...) then installBundledRuntimeDeps(...), which stat/require.resolve()s all 41 specs in the shared runtime-deps root.

Critical issue: installBundledRuntimeDeps receives the same installSpecs array for every plugin — all 33 plugins share an identical 41-spec list pointing at the same versioned runtime-deps root. No memoization or guard checks whether a prior plugin in the same run already resolved an identical spec list. 32 of 33 resolution passes are functionally redundant.

The work is not I/O-heavy (deps already present, "installed in 5–13ms" confirms no extraction), but the synchronous span accumulation across all 33 plugins in a tight for-loop prevents the loop from yielding, producing the observed saturation.

Fix Action

Fix / Workaround

To confirm before/after a patch:

grep "staging bundled runtime deps" ~/.openclaw/logs/gateway.log \
  | grep "$(date +%Y-%m-%d)" \
  | wc -l

Each distinct runId should produce exactly 1 staging line post-fix, not 33.

Happy to send a PR with the patch if maintainers want.

Code Example

openclaw agent --local --agent <any-configured-agent>

---

grep "staging bundled runtime deps" ~/.openclaw/logs/gateway.log \
  | grep "$(date +%Y-%m-%d)" \
  | wc -l

---

// ─── within the plan-build closure containing the for-loop over `candidates` ───
const _runSpecCache = new Map();

function _hashSpecs(specs) {
  return specs.slice().sort().join('\0');
}

// ─── inside the installDeps callback, before calling installBundledRuntimeDeps ───
async function installDeps(record, installSpecs, installBundledRuntimeDeps, logInstalled, measureDiagnosticsTimelineSpanSync) {
  const specHash = _hashSpecs(installSpecs);

  if (_runSpecCache.has(specHash)) {
    const cached = _runSpecCache.get(specHash);
    console.debug(`[plugins] ${record.id} skipped dep resolution (run cache hit, ${installSpecs.length} specs already resolved this run)`);
    logInstalled(record.id, installSpecs, cached);
    return cached;
  }

  let resolved;
  measureDiagnosticsTimelineSpanSync('runtimeDeps.stage', () => {
    resolved = installBundledRuntimeDeps(record, installSpecs);
  });

  _runSpecCache.set(specHash, resolved);
  return resolved;
}
RAW_BUFFERClick to expand / collapse

Summary

  • Every openclaw agent --local (embedded-fallback) invocation triggers a full bundled-runtime-dep resolution pass for every loaded plugin — 33 passes against an identical 41-spec list per run.
  • All 33 plugins share the same spec list (~/.openclaw/plugin-runtime-deps/openclaw-<version>/); no per-plugin subset diffing occurs. The deps are already installed on disk — the work is pure re-verification with no actual installation.
  • Each pass takes 5–13 ms synchronously on the event loop. At 33 plugins × up to 46 embedded runs per minute, the loop never clears between requests.
  • Observed peak: eventLoopUtilization=0.997, eventLoopDelayP99=47,882 ms, runtime-plugins stage consuming 21,860 ms of a single embedded run's 49,248 ms startup time.
  • The fix is a run-scoped Map<specHash, ResolvedSet> allocated once per registry-load call and checked inside the installDeps callback before installBundledRuntimeDeps is invoked. First plugin resolves; the remaining 32 get a cache hit and skip the span entirely.

Environment

  • OpenClaw version: 2026.4.29 (commit a448042)
  • Platform: macOS (darwin 25.3.0, arm64)
  • Node version: 24.x
  • Install method: Homebrew (npm -g via brew install openclaw)
  • Dist bundle: dist/loader-CLyHx60E.js (content-hashed, changes each release)
  • Plugin count: 33 loaded plugins

Reproduction

openclaw agent --local --agent <any-configured-agent>

Or trigger any model-provider rate-limit so the gateway falls over to the embedded path. With 33 plugins loaded, saturation is immediate.

To confirm before/after a patch:

grep "staging bundled runtime deps" ~/.openclaw/logs/gateway.log \
  | grep "$(date +%Y-%m-%d)" \
  | wc -l

Each distinct runId should produce exactly 1 staging line post-fix, not 33.

Observed Metrics (2026-05-01, production gateway)

MetricValue
eventLoopUtilization (peak)0.997
eventLoopDelayP99Ms (peak)47,882 ms
staging bundled runtime deps log lines (single day)613
Peak staging events in one minute46
runtime-plugins stage average per embedded run6,393 ms
core-plugin-tools stage average per embedded run9,345 ms
Worst-case embedded-run totalMs49,248 ms
runtime-plugins when cache is warm (one outlier run)2 ms

Root Cause

File (dist): dist/loader-CLyHx60E.js, lines 4078–4128 File (source): src/plugins/loader.ts Function: prepareBundledPluginRuntimeLoadRoot Inner callback: installDeps

The installDeps callback is invoked once per plugin during the registry-load for-loop. Each invocation runs measureDiagnosticsTimelineSpanSync("runtimeDeps.stage", ...) then installBundledRuntimeDeps(...), which stat/require.resolve()s all 41 specs in the shared runtime-deps root.

Critical issue: installBundledRuntimeDeps receives the same installSpecs array for every plugin — all 33 plugins share an identical 41-spec list pointing at the same versioned runtime-deps root. No memoization or guard checks whether a prior plugin in the same run already resolved an identical spec list. 32 of 33 resolution passes are functionally redundant.

The work is not I/O-heavy (deps already present, "installed in 5–13ms" confirms no extraction), but the synchronous span accumulation across all 33 plugins in a tight for-loop prevents the loop from yielding, producing the observed saturation.

Proposed Fix

Add a run-scoped Map<specHash, ResolvedSet> cache to the plan-build closure that contains the candidates for-loop. Allocated once per registry-load call, doesn't outlive the run.

// ─── within the plan-build closure containing the for-loop over `candidates` ───
const _runSpecCache = new Map();

function _hashSpecs(specs) {
  return specs.slice().sort().join('\0');
}

// ─── inside the installDeps callback, before calling installBundledRuntimeDeps ───
async function installDeps(record, installSpecs, installBundledRuntimeDeps, logInstalled, measureDiagnosticsTimelineSpanSync) {
  const specHash = _hashSpecs(installSpecs);

  if (_runSpecCache.has(specHash)) {
    const cached = _runSpecCache.get(specHash);
    console.debug(`[plugins] ${record.id} skipped dep resolution (run cache hit, ${installSpecs.length} specs already resolved this run)`);
    logInstalled(record.id, installSpecs, cached);
    return cached;
  }

  let resolved;
  measureDiagnosticsTimelineSpanSync('runtimeDeps.stage', () => {
    resolved = installBundledRuntimeDeps(record, installSpecs);
  });

  _runSpecCache.set(specHash, resolved);
  return resolved;
}

Expected Impact

MetricCurrentAfter fix
eventLoopUtilization P990.997~0.35
eventLoopDelayP99Ms47,882 ms~2,000 ms
runtime-plugins avg per embedded run6,393 ms~200 ms
Embedded-run startup worst-case49,248 ms~8,000 ms
staging bundled runtime deps lines per run331

Compatibility

  • No changes to any public API, CLI interface, or plugin SDK contract
  • Cache is strictly internal to the plan-build closure
  • Safe to gate behind a feature flag (e.g., OPENCLAW_PLUGIN_SPEC_CACHE=1) during rollout
  • Behavior under cache miss is byte-for-byte identical to current code path

Test Plan

  1. Log-line count: grep staging bundled runtime deps for one embedded-fallback run → expect 1 line (was 33).
  2. Loop utilization: trigger 10 consecutive embedded-fallback runs → expect eventLoopUtilization < 0.40.
  3. Startup time: runtime-plugins stage time should drop from ~6,400 ms average to <300 ms.
  4. Correctness: openclaw plugins deps --check resolves all 41 specs cleanly.

Happy to send a PR with the patch if maintainers want.

extent analysis

TL;DR

Implement a run-scoped cache to store resolved specs and skip redundant resolution passes for identical spec lists.

Guidance

  • Verify the issue by checking the number of "staging bundled runtime deps" log lines per run, which should be 33 before the fix and 1 after.
  • Apply the proposed fix by adding a Map<specHash, ResolvedSet> cache to the plan-build closure and checking it inside the installDeps callback.
  • Test the fix by running the test plan, which includes checking log-line count, loop utilization, startup time, and correctness.
  • Consider gating the fix behind a feature flag (e.g., OPENCLAW_PLUGIN_SPEC_CACHE=1) during rollout to ensure safe deployment.

Example

The proposed fix includes the following code snippet:

const _runSpecCache = new Map();

function _hashSpecs(specs) {
  return specs.slice().sort().join('\0');
}

async function installDeps(record, installSpecs, installBundledRuntimeDeps, logInstalled, measureDiagnosticsTimelineSpanSync) {
  const specHash = _hashSpecs(installSpecs);

  if (_runSpecCache.has(specHash)) {
    const cached = _runSpecCache.get(specHash);
    console.debug(`[plugins] ${record.id} skipped dep resolution (run cache hit, ${installSpecs.length} specs already resolved this run)`);
    logInstalled(record.id, installSpecs, cached);
    return cached;
  }

  let resolved;
  measureDiagnosticsTimelineSpanSync('runtimeDeps.stage', () => {
    resolved = installBundledRuntimeDeps(record, installSpecs);
  });

  _runSpecCache.set(specHash, resolved);
  return resolved;
}

Notes

The fix assumes that the installBundledRuntimeDeps function is correctly implemented and only needs to be optimized by

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - 💡(How to fix) Fix Gateway: redundant per-plugin dep-resolution saturates event loop in embedded-fallback path [1 comments, 2 participants]