Skip to content

Gateway startup regressed ~5s → ~35s after 4.8 → 4.9 due to repeated jiti normalizeAliases calls #68983

@aliyaredpilled

Description

@aliyaredpilled

Gateway startup regressed ~5s → ~35s after 4.8 → 4.9 due to repeated jiti normalizeAliases calls

Summary

Since 2026.4.9, gateway startup went from ~5s to ~35s in our setup (6 bundled plugins, Node 24). We bisected with npm installs of 4.7/4.8/4.9/4.10/…/4.15 and the regression lands cleanly on 4.9.

Root cause turned out to be an interaction between:

  • jiti's normalizeAliases being O(N²) with a reference-identity sentinel cache (if (e[pt]) return e).
  • 4.9 introduced dist/plugin-sdk/jiti-loader-cache + new callers in setup-registry, doctor-contract-registry, bundled-plugin-metadata, facade-loader/runtime, channel-entry-contract, io, loader, attempt.tool-run-context — each keeps a local jitiLoaders Map and calls getCachedPluginJitiLoader({ cache, modulePath, importerUrl }).
  • buildPluginLoaderAliasMap(modulePath, …) in src/plugins/sdk-alias.ts builds a fresh object via spread every call, so the jiti sentinel never fires — every call runs the full O(N²) cycle.

On a normal startup this fires ~8 times → ~34s of CPU time stuck in normalizeAliases.

Numbers (our setup)

version gateway ready notes
2026.3.28 ~5s getCachedPluginJitiLoader not present
2026.4.8 not measured getCachedPluginJitiLoader not present
2026.4.9 ~35s regression lands here
2026.4.15 ~36s unchanged

CPU profile (--cpu-prof) on 4.15 startup:

normalizeAliases       self CPU   34.0s  (42% of total)
createJiti             self CPU    5.1s  (6.3%)
getCachedPluginJitiLoader distinct nodes: 12
  → 8 cache-miss → new createJiti → new normalizeAliases

Reproducer (self-contained)

// Run against openclaw@2026.4.9 or newer.
import { performance } from 'node:perf_hooks'
import { createJiti } from 'jiti'

// Fake alias map sized roughly like OpenClaw's real one.
const buildFresh = () => Object.fromEntries(
  Array.from({ length: 120 }, (_, i) => [`@pkg-${i}`, `/absolute/path/${i}`])
)

for (let i = 0; i < 8; i++) {
  const alias = buildFresh()             // fresh object ref every iteration
  const t = performance.now()
  createJiti(import.meta.url, { alias }) // triggers normalizeAliases
  console.log(`call ${i + 1}: ${(performance.now() - t).toFixed(0)}ms`)
}
// call 1: ~4000ms
// call 2: ~4000ms   <-- sentinel doesn't fire (new object ref)
// call 3: ~4000ms
// ...

Passing the same object reference skips via sentinel. Passing structurally-identical but different-ref objects does not.

Suggested fixes (in order of blast radius)

  1. Smallest: memoize buildPluginLoaderAliasMap result by input (modulePath + argv1 + moduleUrl + pluginSdkResolution), or by serialized content, so identical content returns the same object ref — jiti's sentinel then fires on second+ use. ~10 lines in src/plugins/sdk-alias.ts.

  2. Better: in src/plugins/jiti-loader-cache.ts, switch scopedCacheKey from ${jitiFilename}::${cacheKey} to just ${cacheKey} (or a stable key that ignores per-plugin modulePath when alias content is shared), and hoist the cache above the per-caller jitiLoaders Map so all 8 call-sites share one loader. Requires verifying that callers don't actually rely on per-caller isolation of jiti state.

  3. Best (upstream): upstream jiti could optionally memoize by content, or replace the O(N²) inner loop with a topological walk. But that's a bigger change and not necessary if (1) or (2) lands in OpenClaw.

Local workaround we applied

We patched node_modules/jiti/dist/jiti.cjs to add a JSON.stringify-keyed cache around the O(N²) body (3 extra lines, preserves the existing [pt]=true sentinel and output semantics). That brought ready from ~34s to ~10.3s with no observed regressions after several hours of production traffic.

// Before
function normalizeAliases(e){ if (e[pt]) return e; /* O(N²) */ }

// After
const __CACHE = new Map()
function normalizeAliases(e){
  if (e[pt]) return e
  const k = JSON.stringify(Object.entries(e).sort())
  const c = __CACHE.get(k); if (c) return c
  /* O(N²) body; stamp pt on result */
  __CACHE.set(k, t); return t
}

Happy to open a PR against either src/plugins/sdk-alias.ts (option 1) or src/plugins/jiti-loader-cache.ts (option 2) if you can point at the preferred direction — option 1 is safer and self-contained; option 2 is more impactful but touches shared plugin-loader semantics.

Environment

  • OpenClaw 2026.4.15 (same behavior on 2026.4.9/4.10/4.11/4.12/4.14)
  • Node.js v24.14.1
  • Linux x86_64, Debian 12
  • 6 plugins loaded (a2a-gateway, agent-relay, context-meter-lite, sliding-window, support-tools, telegram)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    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