Merged
Size
L
Change Breakdown
Bug Fix70%
Refactor30%
#28337fix(core): Use closure-scoped evaluation contexts in VM expression bridge

Expression evaluation corruption fixed with closure isolation

Nested expression evaluations no longer corrupt each other's state. The expression runtime now uses closure-scoped contexts instead of global mutable state, fixing silent failures in AI and langchain workflows.

Expression evaluations in n8n workflows were silently failing when expressions called functions that triggered nested evaluations. For example, using $parameter references or $fromAI() in AI workflows would sometimes return undefined instead of the expected values.

The root cause was global mutable state inside the isolate. When an outer expression evaluation triggered a host-side callback, that callback could start a nested evaluation. The nested evaluation would overwrite the global state that the outer evaluation was still using, corrupting the outer result.

The fix moves all per-evaluation state into closures. Each execute() call now passes its callbacks as closure arguments to evalClosureSync, and a fresh context object is built from those closures rather than from globals. Nested and concurrent evaluations get their own isolated state that cannot interfere with each other.

This change is in the @n8n/expression-runtime package.

View Original GitHub Description

Summary

Replace global mutable state in the isolated-vm expression bridge with closure-scoped evaluation contexts using evalClosureSync. Each execute() call passes callback References as closure arguments ($0, $1, $2), and a global buildContext() function creates a fresh context per evaluation. This enables re-entrant and parallel expression evaluation without state corruption.

Problem: The VM bridge stored per-evaluation state (__data, callbacks) on globalThis inside the isolate. When host-side callbacks (e.g. $parameter proxy) triggered nested expression evaluations, the nested execute() overwrote the global state, corrupting the outer evaluation. This caused $parameter references and $fromAI() to silently return undefined in AI/langchain workflows.

Fix: Move all per-evaluation state into the evalClosureSync closure. Each evaluation gets its own callbacks and context object — they cannot interfere with each other. The resetDataProxies() function is replaced by buildContext() which accepts callbacks as parameters.

Changes by commit:

  1. Parameterize createDeepLazyProxy — accept optional callbacks instead of reading globalThis
  2. Add buildContext() — new context factory alongside existing resetDataProxies()
  3. Restore vmExecuting fallback — temporary legacy fallback for re-entrant calls
  4. Switch to evalClosureSync — the core change, replaces global state with closures
  5. Remove resetDataProxies() — delete old code and global declarations
  6. Clean up — remove temporary workarounds and debug logging

Related Linear tickets, Github issues, and Community forum posts

https://linear.app/n8n/issue/CAT-2775

Review / Merge checklist

  • I have seen this code, I have run this code, and I take responsibility for this code.
  • PR title and summary are descriptive. (conventions)
  • Docs updated or follow-up ticket created.
  • Tests included.
© 2026 · via Gitpulse