Path: blob/main/package/src/common/import-report/report-bundle-async-cycles.md
6452 views
Bundle Async Cycles Detection Tool
Purpose
This tool detects and analyzes async module initialization cycles that cause esbuild bundling failures in Quarto.
The Problem: esbuild's Async Initialization Bug
Root Cause
esbuild has a limitation when bundling modules with top-level await (async initialization). When async initialization propagates through the dependency graph to modules that form import cycles with each other, esbuild generates circular await init_*() dependencies that cannot be resolved.
The Precise Failure Pattern
The build fails when all of these conditions are met:
Root async module exists - A module with actual top-level await (e.g.,
hash.tswith WASM initialization)Async propagates to cyclic modules - The async initialization flows through imports to reach modules in cycles
Cycles exist among async modules - The affected modules have import cycles with each other (not just with non-async modules)
Why This Causes Failures
When esbuild encounters this pattern:
It generates:
This creates a deadlock - each module waits for the other to initialize first. esbuild's bundler sometimes generates invalid JavaScript when trying to handle this, resulting in syntax errors like "Unexpected reserved word 'await'" in non-async contexts.
Important: Cycles Alone Are Not The Problem
The key insight: modules can be in cycles without causing build failures, as long as those cycles don't form among async modules themselves.
For example, this is fine:
But this fails:
Two Solution Strategies
The tool provides two complementary approaches to fix these issues:
Strategy 1: Chain Breaking (OUTSIDE cycles)
Approach: Break async propagation chains BEFORE they reach modules that cycle with each other.
How it works:
Trace paths from root async modules to cycle files
Find edges from non-cycle files into cycle files
Make those imports dynamic to stop async propagation
Advantages:
Usually requires fewer changes (1-2 dynamic imports)
Strategic - breaks at entry points to problematic cycles
Prevents async from infecting the cycle cluster
Example:
Strategy 2: MFAS - Minimum Feedback Arc Set (WITHIN cycles)
Approach: Break cycles among async modules themselves.
How it works:
Build subgraph of async modules in cycles + their neighbors
Find minimum set of edges to remove to eliminate cycles
Uses ILP (Integer Linear Programming) optimization
Advantages:
Eliminates the cycles entirely
May be necessary when entry points can't be modified
Provides alternative if chain breaking isn't sufficient
Example: If async modules A, B, C form a cycle, MFAS identifies the minimum edges to make dynamic to break that cycle.
How The Tool Works
1. Detection Phase
2. Analysis Phase
3. Output
The tool provides:
List of async modules in cycles
Chain breaking recommendations (minimum edges OUTSIDE cycles)
MFAS recommendations (minimum edges WITHIN cycles)
Affected files for each recommendation
Interpreting Results
When Build Succeeds
If the build works and the tool reports:
"N async modules in cycles"
"No cycles found containing async modules"
This means:
✅ Async modules exist in cycles with non-async modules (fine!)
✅ No cycles exist among async modules themselves (what we want!)
✅ The dangerous pattern is not present
When Build Fails
If the tool reports:
"N async modules in cycles"
"Found M cycle(s) containing async modules"
This means:
❌ Cycles exist among async modules themselves
❌ The dangerous pattern is present
🔧 Apply the recommended dynamic imports to fix
Example Scenarios
Scenario A: Safe (Build Works)
Result: No cycles among async modules → Build succeeds
Scenario B: Unsafe (Build Fails)
Result: Cycles among async modules → Build fails
Scenario C: Fixed with Chain Breaking
Result: Async doesn't reach the cycles → Build succeeds
Implementation Details
Cycle Detection
Uses DFS-based cycle detection with a limit of 1000 cycles for tractability.
ILP Optimization
Both chain breaking and MFAS use Set Cover formulation:
Variables: Binary (0/1) for each edge - should it be broken?
Constraints: Each chain/cycle must have at least one edge broken
Objective: Minimize total edges broken
This finds the optimal (minimum) set of edges to break.
Subgraph Construction (MFAS)
The MFAS approach builds a focused subgraph:
This captures cycles involving async modules while keeping the problem tractable.
Usage
The entry point determines which cycles are analyzed - it should be the main entry to your application.
Files Modified
The tool analyzes but does not modify any files. It provides recommendations that developers can implement:
Chain Breaking typically affects:
Command files (check-render.ts, command-utils.ts)
Entry points into render subsystems
MFAS typically affects:
Core modules that cycle with each other
Render, project, and engine modules
Testing
After applying fixes:
Success indicators:
Build completes without "Unexpected reserved word" errors
Tool reports "No cycles found containing async modules"
Minimal dynamic imports (typically 2-4)
References
Original issue: Quarto bundling fails with async initialization in cycles
Tool location:
package/src/common/import-report/report-bundle-async-cycles.tsRelated tools:
explain-all-cycles.ts- Generates cycle datareport-import-chains.ts- Analyzes import chains
Key Takeaways
Cycles are OK - Import cycles don't cause build failures by themselves
Async cycles are NOT OK - Cycles among async modules cause esbuild to generate invalid code
Two solutions - Break chains before cycles, or break cycles themselves
Strategic fixes - Use ILP optimization to find minimum changes needed
Dynamic imports - The workaround is making strategic imports dynamic to defer module loading