Skip to content

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

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 [email protected] 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

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions