Skip to content

Fix buildVM promise cleanup ordering (#2483)#2484

Open
justin808 wants to merge 5 commits intomasterfrom
jg/2483-buildvm-race-fix
Open

Fix buildVM promise cleanup ordering (#2483)#2484
justin808 wants to merge 5 commits intomasterfrom
jg/2483-buildvm-race-fix

Conversation

@justin808
Copy link
Member

@justin808 justin808 commented Feb 25, 2026

Summary

Fixes an intermittent SSR error: "The ReactOnRails value exists in the [object Object] scope, it may not be safe to overwrite it"

Fixes the buildVM() promise cleanup ordering so that a failed build doesn't leave a stale rejected promise permanently stuck in the vmCreationPromises map, blocking all retries for that bundle path.

Root cause

The node renderer's buildVM() in worker/vm.ts uses a vmCreationPromises Map to deduplicate concurrent calls for the same bundle. The cleanup (vmCreationPromises.delete()) was inside a try/finally block in the async IIFE, but vmCreationPromises.set() ran after the IIFE was created.

An async IIFE executes synchronously until its first await. If the code throws before any await, the IIFE's finally block runs synchronouslybefore vmCreationPromises.set() stores the promise. This leaves a stale rejected promise permanently in the map.

Here's the exact sequence with line numbers from the unpatched vm.ts:

                    MODULE-LEVEL STATE:
                    ┌──────────────────────────────────────────────┐
                    │ vmCreationPromises = Map<string, Promise>    │
                    │ vmContexts = Map<string, VMContext>          │
                    └──────────────────────────────────────────────┘

CALL 1 enters buildVM('bundle.js')
│
│  Line 183: vmCreationPromises.has('bundle.js') → false
│  Line 189: vmContexts.get('bundle.js') → undefined
│
│  Line 197: const vmCreationPromise = (async () => {
│              // IIFE starts executing synchronously...
│
│  Line 234:   const context = vm.createContext(contextObject);
│  Line 291:   const bundleContents = await readFileAsync(filePath, 'utf8');
│              ┌──────────────────────────────────────────────┐
│              │  await YIELDS — event loop runs other work   │
│              └──────────────────────────────────────────────┘
│              │
│              │  CALL 2 enters buildVM('bundle.js')
│              │  │
│              │  │  Line 183: vmCreationPromises.has('bundle.js')
│              │  │           → true (set() ran on line 345)
│              │  │  Line 184: return vmCreationPromises.get('bundle.js')
│              │  │           → gets the SAME promise as Call 1
│              │  │           ✓ CORRECT — Call 2 waits for Call 1
│              │
│  Call 1 resumes — builds VM successfully
│  Line 333: return true;
│
│  Both Call 1 and Call 2 resolve with `true`. ✓ No race.
│
│  Line 338: finally { vmCreationPromises.delete('bundle.js') }
│  Line 345: vmCreationPromises.set('bundle.js', vmCreationPromise) ← already done
│
│  ✓ Map is clean, VM is built, everyone's happy.


BUT WHAT IF THE IIFE THROWS *BEFORE* THE FIRST AWAIT?

CALL 1 enters buildVM('nonexistent-bundle.js')
│
│  Line 183: vmCreationPromises.has('nonexistent-bundle.js') → false
│
│  Line 197: const vmCreationPromise = (async () => {
│              // IIFE starts executing synchronously...
│              // Suppose code throws before line 291 (the first await)
│
│              💥 SYNCHRONOUS THROW
│
│  Line 338:   finally { vmCreationPromises.delete('nonexistent-bundle.js') }
│              ─── runs SYNCHRONOUSLY, but the map is empty (set() hasn't run yet!) ───
│              ─── delete() is a no-op ───
│
│  Line 345: vmCreationPromises.set('nonexistent-bundle.js', vmCreationPromise)
│            ─── STORES A REJECTED PROMISE in the map ───
│
│  Now vmCreationPromises permanently contains a rejected promise.
│
│  CALL 2 enters buildVM('nonexistent-bundle.js')  ← retry attempt
│  │
│  │  Line 183: vmCreationPromises.has('nonexistent-bundle.js')
│  │           → true (stale rejected promise!)
│  │  Line 184: return vmCreationPromises.get(...)
│  │           → immediately rejects
│  │
│  💥 PERMANENTLY POISONED — every retry gets the stale rejected promise

Fix

Move cleanup from the IIFE's try/finally to a .finally() chained on the promise after vmCreationPromises.set():

// BEFORE (broken):
const vmCreationPromise = (async () => {
  try {
    // ... build VM ...
  } finally {
    vmCreationPromises.delete(filePath);  // ← can run before set()!
  }
})();
vmCreationPromises.set(filePath, vmCreationPromise);

// AFTER (fixed):
const vmCreationPromise = (async () => {
  try {
    // ... build VM ...
  }
  // No finally here
})();
vmCreationPromises.set(filePath, vmCreationPromise);  // ← runs first
void vmCreationPromise
  .catch(() => {})
  .finally(() => {
    vmCreationPromises.delete(filePath);  // ← guaranteed to run after set()
  });

Chaining .finally() on the promise after set() guarantees cleanup runs as a microtask after the current synchronous execution, so set() has always completed first — regardless of whether the failure is synchronous or asynchronous.

Test plan

  • All 27 existing vm.test.ts tests pass
  • All 13 handleRenderRequest.test.ts tests pass
  • New test: buildVM recovers after failure for nonexistent bundle verifies the cleanup ordering fix
  • ESLint passes

Fixes #2483

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Explicitly remove cached VM instances and their internal tracking via removeVM().
  • Bug Fixes

    • Prevent duplicate concurrent VM builds by synchronizing creation.
    • Recover reliably after VM initialization failures, including synchronous throws, allowing retries.
    • Ensure cleanup timing prevents stale internal entries after failures.
  • Tests

    • Added a test verifying recovery when VM creation throws synchronously.

Move vmCreationPromises cleanup from a try/finally inside the async IIFE
to a .finally() on the promise chain after vmCreationPromises.set(). The
IIFE's finally block runs synchronously before set() when code throws
before the first await, leaving a rejected promise permanently stuck in
the map and preventing retries for that bundle path.

Add test verifying buildVM recovers after failure for a nonexistent bundle.

Fixes #2483

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 25, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ee5bfbb and 357e390.

📒 Files selected for processing (1)
  • packages/react-on-rails-pro-node-renderer/src/worker/vm.ts

Walkthrough

Stores VM-creation promises immediately to block concurrent builds, moves cleanup to a .finally() chained on the stored promise (with a non-throwing .catch() guard), ensures entries are removed after settlement, exposes removeVM(bundlePath: string) to also clear creation promises, and adds a test for synchronous throws before the first await.

Changes

Cohort / File(s) Summary
VM Synchronization & API
packages/react-on-rails-pro-node-renderer/src/worker/vm.ts
Immediately store vmCreationPromise to prevent concurrent duplicate builds; change cleanup to .catch().finally() chained on the stored promise so the map entry is removed after settlement; add removeVM(bundlePath: string) behavior to also delete entries from vmCreationPromises; update resetVM() to clear vmCreationPromises; add explanatory comments about synchronous throws and cleanup ordering.
VM Behavior Test
packages/react-on-rails-pro-node-renderer/tests/vm.test.ts
Add test "buildVM recovers after synchronous throw before first await": spies on vm.createContext to throw synchronously, verifies initial rejection, restores createContext, retries buildVM and asserts a VM context is created—validates cleanup of vmCreationPromises on synchronous failures.

Sequence Diagram(s)

sequenceDiagram
    participant ClientA as Client A
    participant ClientB as Client B
    participant Promises as vmCreationPromises
    participant FS as FileSystem
    participant VM as VM Context

    ClientA->>Promises: check for existing promise
    alt no promise
        ClientA->>Promises: store vmCreationPromise (immediately)
        ClientA->>FS: read bundle (async)
    end
    ClientB->>Promises: sees vmCreationPromise -> await it
    FS-->>ClientA: bundle contents
    ClientA->>VM: vm.runInContext(bundle) -> set context & vmBundleFilePath
    ClientA->>Promises: resolve vmCreationPromise
    Promises-->>ClientB: promise settles, awaiting clients proceed
    Promises->>Promises: storedPromise.catch().finally() -> delete map entry
    Note right of VM: `removeVM(bundlePath)` can be called to delete VM/context and map entry
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I stashed a promise in a tidy map,
Held it firm so no two builds would slap.
A sync-throw hopped, then off it went,
I cleared the trace—so retries are spent.
Carrots, code, and careful naps.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title "Fix buildVM promise cleanup ordering" clearly and concisely describes the main change, directly addressing the core fix that resolves issue #2483.
Linked Issues check ✅ Passed The PR successfully addresses all objectives from #2483: prevents concurrent buildVM race conditions via promise-based deduplication, ensures vmCreationPromises cannot contain stale/rejected promises, and includes tests for synchronous throw recovery.
Out of Scope Changes check ✅ Passed All changes in vm.ts and vm.test.ts are directly scoped to fixing the buildVM race condition and cleanup ordering; no unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch jg/2483-buildvm-race-fix

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link

greptile-apps bot commented Feb 25, 2026

Greptile Summary

Fixes a subtle race condition in buildVM() promise cleanup logic.

Problem: When buildVM() failed synchronously (before first await), the IIFE's finally block executed immediately, calling vmCreationPromises.delete() before vmCreationPromises.set() ran. This left a rejected promise stuck in the map, permanently blocking retries for that bundle path.

Solution: Moved cleanup from try/finally inside the IIFE to a .finally() handler chained on the stored promise. This guarantees cleanup runs after vmCreationPromises.set(), regardless of sync vs async failure timing.

Key changes:

  • Removed finally block from async IIFE vm.ts:344
  • Added .catch(() => {}).finally() chain on stored promise vm.ts:354-358 to handle cleanup after promise settles
  • Added comprehensive comments explaining the event loop timing subtlety
  • Test verifies failed buildVM for nonexistent bundle allows subsequent retry to succeed

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk
  • The fix correctly addresses a well-documented race condition with a sound solution. The .finally() chain approach guarantees cleanup ordering, the .catch(() => {}) properly swallows rejections to prevent unhandled promise warnings, and the new test provides regression coverage. All existing tests pass.
  • No files require special attention

Important Files Changed

Filename Overview
packages/react-on-rails-pro-node-renderer/src/worker/vm.ts Fixes race condition by moving promise cleanup from try/finally to .finally() chain, ensuring cleanup runs after promise is stored in map
packages/react-on-rails-pro-node-renderer/tests/vm.test.ts Adds regression test verifying buildVM cleanup works correctly after failed bundle load

Sequence Diagram

sequenceDiagram
    participant Caller
    participant buildVM
    participant Map as vmCreationPromises Map
    participant IIFE as Async IIFE
    
    Note over Caller,IIFE: Scenario: Synchronous failure (e.g., file doesn't exist)
    
    Caller->>buildVM: buildVM(nonexistent.js)
    buildVM->>buildVM: Check if promise exists in map
    buildVM->>IIFE: Create async IIFE
    Note over IIFE: IIFE runs synchronously until first await
    IIFE->>IIFE: readFileAsync throws (sync)<br/>before first await
    
    Note over buildVM,IIFE: OLD CODE (Bug): finally runs immediately
    IIFE--xIIFE: finally: vmCreationPromises.delete()
    Note over Map: ❌ Map entry doesn't exist yet!
    
    buildVM->>Map: vmCreationPromises.set(path, promise)
    Note over Map: ❌ Rejected promise stuck in map
    buildVM-->>Caller: Return rejected promise
    
    Note over Caller,Map: NEW CODE (Fix): cleanup after set()
    
    Caller->>buildVM: buildVM(nonexistent.js)
    buildVM->>buildVM: Check if promise exists in map
    buildVM->>IIFE: Create async IIFE
    IIFE->>IIFE: readFileAsync throws
    buildVM->>Map: vmCreationPromises.set(path, promise)
    buildVM->>buildVM: Chain .catch().finally() on promise
    Note over buildVM: .catch() swallows rejection
    IIFE-->>buildVM: Promise rejects
    buildVM->>Map: .finally(): vmCreationPromises.delete(path)
    Note over Map: ✅ Map cleaned up correctly
    buildVM-->>Caller: Return rejected promise
Loading

Last reviewed commit: caf0e0b

@github-actions
Copy link
Contributor

github-actions bot commented Feb 25, 2026

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.5 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.5 KB (0%)
react-on-rails/client bundled (brotli) 53.71 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.71 KB (0%)
react-on-rails-pro/client bundled (gzip) 63.5 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 63.5 KB (0%)
react-on-rails-pro/client bundled (brotli) 54.67 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 54.67 KB (0%)
registerServerComponent/client bundled (gzip) 127.16 KB (0%)
registerServerComponent/client bundled (gzip) (time) 127.16 KB (0%)
registerServerComponent/client bundled (brotli) 61.54 KB (0%)
registerServerComponent/client bundled (brotli) (time) 61.54 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 121.69 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 121.69 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 56.63 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.63 KB (0%)

@claude
Copy link

claude bot commented Feb 25, 2026

Code Review

Summary

The fix correctly addresses the race condition: when the async IIFE throws synchronously before its first await, the old try/finally block ran its cleanup before vmCreationPromises.set() was called, leaving a stale rejected promise permanently stuck in the map. Moving cleanup to .catch(() => {}).finally() chained after set() guarantees ordering is correct regardless of whether the failure is sync or async. The .catch(() => {}) suppressor before .finally() is also necessary to prevent an unhandled-rejection warning on the voided chain — all of that is correct.


Issues

1. Test does not exercise the actual bug (most important)

The new test triggers the bundle-not-found error via readFileAsync(filePath) at line 297, which is async (after the first await in the IIFE). For async failures the old code already worked correctly: set() runs synchronously before the first await completes, so by the time the async failure propagates back, set() has already been called and cleanup via finally deletes the right entry.

The bug only bites when code throws synchronously before the first await — e.g. if getConfig(), new SharedConsoleHistory(), or vm.createContext() throw. This test would pass unchanged against the original, unfixed code, so it doesn't serve as a regression guard for the specific ordering problem.

A minimal fix to the test would be to mock one of the early synchronous calls (e.g. getConfig) to throw, verify that buildVM rejects, and then verify that calling it again (or for the same path after the mock is restored) resolves rather than returning the stale rejection.

2. Test's recovery assertion uses a different path

After failing for nonExistentPath, the test calls buildVM(serverBundlePath) — a completely different key. The stale promise (if any) lives under nonExistentPath, so it would never interfere with serverBundlePath anyway. The test never actually checks that nonExistentPath itself can be retried (or that its map entry was cleaned up). A comment saying the cleanup "allows a retry" is misleading when the retry is for a different path.


Minor observations

  • The comment block (lines 195-201) is very thorough; it serves as excellent documentation for a subtle JS concurrency nuance and is worth keeping.
  • No concerns on the void + .catch(() => {}).finally() idiom — it's the idiomatic way to fire-and-forget cleanup on a promise chain without leaking rejections.

const nonExistentPath = path.resolve(__dirname, './tmp/nonexistent-bundle.js');

// First call should fail because the bundle file does not exist
await expect(buildVM(nonExistentPath)).rejects.toThrow();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failure here is triggered by readFileAsync(filePath, 'utf8') on line 297 of vm.ts, which is after the first await in the IIFE. Async failures were handled correctly by the old code too — vmCreationPromises.set() runs synchronously before any await completes, so the old finally inside the IIFE would fire after set() for async failures. This means the original buggy code would also pass this test.

To actually exercise the race condition the fix targets, the test needs to trigger a synchronous throw before the first await — e.g. by temporarily making getConfig() throw:

test('buildVM cleans up map after synchronous throw before first await', async () => {
  const originalGetConfig = getConfig;
  jest.spyOn(/* module */, 'getConfig').mockImplementationOnce(() => {
    throw new Error('sync config failure');
  });

  await expect(buildVM(serverBundlePath)).rejects.toThrow('sync config failure');

  // With the old code, serverBundlePath would be stuck in vmCreationPromises
  // as a stale rejected promise. With the fix, the entry is deleted and this retry succeeds.
  await expect(buildVM(serverBundlePath)).resolves.toBe(true);
});

Without a test like this, there is no regression guard for the specific ordering bug.


// After failure, the vmCreationPromises map should be cleaned up,
// allowing a retry with the correct path to succeed
await buildVM(serverBundlePath);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This recovery call uses a different path (serverBundlePath) from the failed call (nonExistentPath). The stale rejected promise, if any, would be stored under nonExistentPath, not serverBundlePath, so this assertion would succeed regardless of whether cleanup for nonExistentPath occurred. The comment above ("allowing a retry") is misleading — this isn't a retry for the same path.

Consider either retrying the same path (after creating the file or restoring a mock) or at least adding a direct assertion that nonExistentPath is no longer in vmCreationPromises after the failure.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/react-on-rails-pro-node-renderer/src/worker/vm.ts (1)

363-375: resetVM does not clear vmCreationPromises — potential stale-state risk in tests.

resetVM() clears vmContexts but leaves vmCreationPromises intact. If a test calls resetVM() while a buildVM is still in-flight (or a rejected promise hasn't been cleaned up yet by the microtask), a subsequent buildVM for the same path would return the stale/rejected promise from the map. This doesn't appear to be triggered by current tests, but it's a latent footgun.

Consider clearing vmCreationPromises in resetVM for safety:

Proposed fix
 export function resetVM() {
   // Clear all VM contexts
   vmContexts.clear();
+  vmCreationPromises.clear();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro-node-renderer/src/worker/vm.ts` around lines 363
- 375, The resetVM function currently clears vmContexts but leaves
vmCreationPromises intact, risking stale or rejected promise reuse; update
resetVM (the exported function named resetVM) to also clear the
vmCreationPromises Map/collection so any in-flight or failed buildVM promises
are removed, ensuring subsequent calls to buildVM won't return stale promises;
locate the vmCreationPromises symbol in this module and call its clear/delete
logic alongside vmContexts.clear() (and consider also removing the removeVM
behavior if it should mirror reset semantics).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/react-on-rails-pro-node-renderer/tests/vm.test.ts`:
- Around line 545-559: The test uses different paths and may hit cached state so
it doesn't verify cleanup of the vmCreationPromises map; change the test to (1)
call resetVM() at the start to clear any cached vmContexts, (2) use the same
path (nonExistentPath) for both the failing attempt and the retry (create the
file between attempts or write serverBundlePath contents to nonExistentPath),
and (3) after the rejected buildVM(nonExistentPath) assert that
vmCreationPromises (internal map) has had its entry removed or that the map is
empty before retrying, then call buildVM(nonExistentPath) and assert
hasVMContextForBundle(nonExistentPath) is true so the test actually exercises
cleanup logic in buildVM.

---

Nitpick comments:
In `@packages/react-on-rails-pro-node-renderer/src/worker/vm.ts`:
- Around line 363-375: The resetVM function currently clears vmContexts but
leaves vmCreationPromises intact, risking stale or rejected promise reuse;
update resetVM (the exported function named resetVM) to also clear the
vmCreationPromises Map/collection so any in-flight or failed buildVM promises
are removed, ensuring subsequent calls to buildVM won't return stale promises;
locate the vmCreationPromises symbol in this module and call its clear/delete
logic alongside vmContexts.clear() (and consider also removing the removeVM
behavior if it should mirror reset semantics).

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1123f0c and caf0e0b.

📒 Files selected for processing (2)
  • packages/react-on-rails-pro-node-renderer/src/worker/vm.ts
  • packages/react-on-rails-pro-node-renderer/tests/vm.test.ts

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is ON, but it could not run because Privacy Mode (Legacy) is turned on. To enable Bugbot Autofix, switch your privacy mode in the Cursor dashboard.

// allowing a retry with the correct path to succeed
await buildVM(serverBundlePath);
expect(hasVMContextForBundle(serverBundlePath)).toBeTruthy();
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test doesn't validate the cleanup ordering fix

Medium Severity

This test doesn't actually exercise the fix it claims to verify. The failure path uses nonExistentPath while the retry uses serverBundlePath — a different key in vmCreationPromises — so the retry's success is entirely independent of whether the failed path's entry was cleaned up. Additionally, the nonexistent-file error occurs at await readFileAsync (the first await), making it an async failure, whereas the bug being fixed only affects synchronous throws before the first await. This test would pass identically on both old and new code, leaving the actual race condition fix with no regression coverage.

Fix in Cursor Fix in Web

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 25, 2026

Review: Fix buildVM promise cleanup ordering

The core fix is correct and well-motivated. Moving cleanup from try/finally inside the IIFE to .finally() chained after vmCreationPromises.set() properly closes the race condition where synchronous throws before the first await could let delete run before set. The .catch(() => {}) pattern before .finally() is also correct — it prevents a spurious unhandled-rejection warning on the side chain while the original rejected promise is still returned to (and handled by) the caller.


Issues

1. The test does not exercise the actual bug path

The new test triggers the error via a non-existent file, which fails at:

const bundleContents = await readFileAsync(filePath, 'utf8');  // line 297

This is the first await in the IIFE — so the failure is asynchronous. By the time readFileAsync rejects, the microtask queue has already yielded, and vmCreationPromises.set() below the IIFE has already executed. The old try/finally inside the IIFE would have handled this path correctly too.

The race condition only bites when code throws synchronously before the first await — for example if vm.createContext(), extendContext(), or a vm.runInContext() call throws. A test that truly guards this regression would need to force such a synchronous throw (e.g., by mocking vm.createContext to throw).

As written, the test passes with both the old code and the new code, so it doesn't actually verify the fix.

2. Misleading example in the inline comment

The comment at the fix site says:

e.g. readFileAsync rejects for a missing file

…as an example of something that "throws before reaching any await". But readFileAsync is called via await and is the first await in the IIFE. Its rejection propagates asynchronously — after the synchronous frame that calls vmCreationPromises.set() has completed. The example is the opposite of what the comment intends to illustrate.

A more accurate example would be: vm.createContext() throwing, or extendContext() throwing — operations that run before any await.

3. Hardcoded line number in comment

The comment reads:

before vmCreationPromises.set() on line 349 has executed

Line numbers shift whenever the surrounding code changes, making this stale almost immediately. Referring to the call by name/description instead of by line number would be more durable.


Minor pre-existing note (not introduced by this PR)

resetVM() clears vmContexts but not vmCreationPromises. If resetVM() is called while a build is in flight, the in-progress promise remains in the map until the .finally() callback fires after settlement. This is not introduced here, but it is worth keeping in mind if resetVM() is ever used in production paths rather than just test teardown.

// Why .finally() here instead of try/finally inside the async IIFE:
//
// An async IIFE executes synchronously until its first `await`. If the code
// throws before reaching any `await` (e.g. readFileAsync rejects for a
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example "readFileAsync rejects for a missing file" is inaccurate here. readFileAsync is called with await on line 297 and is the first await in the IIFE — so its rejection is asynchronous. By the time readFileAsync rejects, vmCreationPromises.set() has already run and there is no stale entry.

The synchronous-throw scenario (the actual bug path) is triggered by code that runs before await readFileAsync: e.g., vm.createContext(), extendContext(), or any of the vm.runInContext() calls throwing.

Suggest replacing the example:

Suggested change
// throws before reaching any `await` (e.g. readFileAsync rejects for a
// throws before reaching any `await` (e.g. `vm.createContext()` or
// a `vm.runInContext()` call throwing synchronously before `readFileAsync`),

// An async IIFE executes synchronously until its first `await`. If the code
// throws before reaching any `await` (e.g. readFileAsync rejects for a
// missing file), the IIFE's internal finally block runs *synchronously* —
// before `vmCreationPromises.set()` on line 349 has executed. That means
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line number references become stale as the code evolves. Consider referring to the call semantically instead:

Suggested change
// before `vmCreationPromises.set()` on line 349 has executed. That means
// before `vmCreationPromises.set()` (below) has executed. That means

Makes the subtle timing bug more approachable before diving into
the technical explanation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 25, 2026

Review: Fix buildVM promise cleanup ordering

The fix correctly identifies and addresses a real race condition in JavaScript's async function behavior: when an async IIFE throws synchronously (before its first await), the finally block runs as part of normal JS exception propagation — synchronously, before the caller can execute vmCreationPromises.set(). Moving cleanup to .finally() chained after set() is the right fix.

Core fix is sound. A few issues worth addressing before merging:


1. Test doesn't exercise the actual bug path

The new test triggers a failure via a nonexistent file, which fails at await readFileAsync(filePath, 'utf8') — an async error (after the first await). For async errors, the original try/finally in the IIFE would already run after set(), so the old code handles this correctly too. The test passes on both old and new code.

The bug only manifests for synchronous errors thrown before the first await. To properly test the fix, the test should trigger a sync failure — for example by mocking getConfig() to throw, then verifying the same path can be retried after the mock is restored.

2. removeVM doesn't clean up vmCreationPromises

The newly added removeVM function only deletes from vmContexts, leaving a stale in-flight promise entry in vmCreationPromises if called during an active build. Callers of removeVM would expect the VM to be fully removed.

3. resetVM also doesn't clear vmCreationPromises (pre-existing)

This pre-dates the PR, but resetVM() only clears vmContexts. If tests call resetVM() while a build is in-flight, the promise map gets orphaned entries. Since resetVM is used in the test beforeEach, this could cause subtle test interactions. Not a blocker for this PR but worth a follow-up.

const nonExistentPath = path.resolve(__dirname, './tmp/nonexistent-bundle.js');

// First call should fail because the bundle file does not exist
await expect(buildVM(nonExistentPath)).rejects.toThrow();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't actually exercise the bug being fixed. The failure for nonExistentPath occurs at await readFileAsync(filePath, 'utf8'), which is an async error (after the first await in the IIFE). For async errors, the original try/finally inside the IIFE already runs after vmCreationPromises.set(), so the old code handles this case correctly.

The race condition only triggers for synchronous errors thrown before the first await. A test that properly validates the fix would need to trigger a sync failure, e.g.:

// mock getConfig() to throw synchronously
jest.spyOn(configModule, 'getConfig').mockImplementationOnce(() => { throw new Error('sync config error'); });

await expect(buildVM(somePath)).rejects.toThrow();

// restore normal config, then verify the same path can be retried
jest.restoreAllMocks();
await buildVM(somePath); // would hang forever with the old code (stale rejected promise in map)


return vmCreationPromise;
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removeVM only clears from vmContexts but not vmCreationPromises. If called while a build is in-flight, the promise entry stays in the map and any concurrent caller awaiting it will still receive the result of the removed build. Should also clear the promise map:

export function removeVM(bundlePath: string) {
  vmContexts.delete(bundlePath);
  vmCreationPromises.delete(bundlePath);
}

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-on-rails-pro-node-renderer/src/worker/vm.ts (1)

377-381: ⚠️ Potential issue | 🟡 Minor

resetVM should also clear vmCreationPromises for a clean test slate.

When resetVM() is called synchronously during test setup, any in-flight promise's .finally() cleanup microtask may not have executed yet. This leaves stale entries in vmCreationPromises. When the next test calls buildVM() with the same bundle path, the has() check at line 183 will find the stale promise and return it instead of creating a fresh build, potentially causing tests to fail with cached results.

Proposed fix
 export function resetVM() {
   // Clear all VM contexts
   vmContexts.clear();
+  vmCreationPromises.clear();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro-node-renderer/src/worker/vm.ts` around lines 377
- 381, The resetVM function currently only clears vmContexts but must also clear
vmCreationPromises to avoid returning stale in-flight promises from buildVM;
update the resetVM implementation to call vmCreationPromises.clear() (in
addition to vmContexts.clear()) so tests get a clean slate and subsequent
buildVM has() checks won't find stale entries.
🧹 Nitpick comments (1)
packages/react-on-rails-pro-node-renderer/src/worker/vm.ts (1)

383-389: removeVM is currently unused — consider deferring until needed.

This is dead code with a TODO acknowledging it's not yet used. If there's a planned use case, that's fine to keep. Otherwise, YAGNI suggests deferring to avoid maintaining untested surface area.

Also, if a build is somehow in progress for bundlePath, this only removes the finished context but not the in-progress promise from vmCreationPromises, so a concurrent buildVM call could still see the old promise. This may be fine if the function is only intended for post-build cleanup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro-node-renderer/src/worker/vm.ts` around lines 383
- 389, removeVM is dead code and, if retained, is unsafe because it only deletes
vmContexts and leaves any in-progress promise in vmCreationPromises; either
remove the removeVM function entirely (defer until needed) or update it to also
clear the corresponding entry in vmCreationPromises and document/handle
in-progress builds (e.g., cancel or guard against race with buildVM). Locate the
removeVM function and either delete it from the module or modify it so it
deletes both vmContexts.delete(bundlePath) and
vmCreationPromises.delete(bundlePath), and add a brief comment in buildVM about
races with in-progress promises.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/react-on-rails-pro-node-renderer/src/worker/vm.ts`:
- Around line 377-381: The resetVM function currently only clears vmContexts but
must also clear vmCreationPromises to avoid returning stale in-flight promises
from buildVM; update the resetVM implementation to call
vmCreationPromises.clear() (in addition to vmContexts.clear()) so tests get a
clean slate and subsequent buildVM has() checks won't find stale entries.

---

Nitpick comments:
In `@packages/react-on-rails-pro-node-renderer/src/worker/vm.ts`:
- Around line 383-389: removeVM is dead code and, if retained, is unsafe because
it only deletes vmContexts and leaves any in-progress promise in
vmCreationPromises; either remove the removeVM function entirely (defer until
needed) or update it to also clear the corresponding entry in vmCreationPromises
and document/handle in-progress builds (e.g., cancel or guard against race with
buildVM). Locate the removeVM function and either delete it from the module or
modify it so it deletes both vmContexts.delete(bundlePath) and
vmCreationPromises.delete(bundlePath), and add a brief comment in buildVM about
races with in-progress promises.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f585f1c and fef7fba.

📒 Files selected for processing (1)
  • packages/react-on-rails-pro-node-renderer/src/worker/vm.ts

Copy link
Collaborator

@AbanoubGhadban AbanoubGhadban left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this! The .finally() cleanup ordering fix is a correct improvement — chaining it after .set() guarantees the map entry is always cleaned up regardless of when the promise settles.

However, I want to flag that the connection to the "ReactOnRails value exists" error described in #2483 doesn't hold. That error was caused by the old v3.3.1 architecture where context was a module-level variable that got overwritten by concurrent buildVM calls at the await boundary. This was already fixed in react_on_rails_pro#527, which introduced the vmCreationPromises Map and made context a local variable inside the IIFE — so concurrent calls can no longer share or overwrite each other's context.

The bug this PR actually fixes (stale rejected promise when code throws synchronously before the first await) is real but separate — it would permanently block retries for a bundle path, not cause a double-initialization of ReactOnRails. I'd suggest updating the PR description to reflect what this actually fixes.

expect(vmContext1).toBe(vmContext2);
});

test('buildVM recovers after failure for nonexistent bundle', async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test does not exercise the specific bug being fixed. Two issues:

  1. The failure happens at readFileAsync (the first await), where the original try/finally cleanup already works correctly — the finally runs as a microtask after set() has been called.
  2. The retry uses serverBundlePath (a different path than nonExistentPath), so even if a stale promise were left for nonExistentPath, it would not affect the retry.

To properly validate the fix, the test would need to trigger a synchronous throw before the first await (e.g., by mocking vm.createContext to throw), then retry with the same path and assert it succeeds.

…e comments

- Rewrite buildVM recovery test to trigger synchronous throw (via
  vm.createContext mock) before the first await, which is the actual
  bug scenario. The old test used a nonexistent file path which fails
  asynchronously at readFileAsync — already handled by the old code.
- Fix test to retry the SAME path after failure, proving the
  vmCreationPromises map was properly cleaned up.
- Fix inaccurate comment example in vm.ts (readFileAsync → vm.createContext)
- Replace stale line number reference with semantic reference
- Add vmCreationPromises.delete() to removeVM to prevent stale entries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 27, 2026

Review: Fix buildVM promise cleanup ordering (#2483)

The root cause analysis is correct and well-documented. The fix is sound: moving cleanup from try/finally inside the IIFE to a .finally() chained after vmCreationPromises.set() properly handles the race where a synchronous throw before the first await would leave a stale rejected promise in the map. The new test covers exactly the failure scenario.

A few issues worth addressing:

Bug: resetVM() doesn't clear vmCreationPromises

removeVM() was correctly updated to also call vmCreationPromises.delete(), but resetVM() still only calls vmContexts.clear(). If resetVM() is called while a VM creation is in-flight, the pending/rejected promise remains in vmCreationPromises. Any subsequent call to buildVM() on the same path will hit the deduplication check (vmCreationPromises.has()) and return the stale promise from before the reset.

The inconsistency between resetVM and removeVM is a latent bug. resetVM should also call vmCreationPromises.clear().

Test: config mutation not restored

The new test sets config.supportModules = true and config.stubTimers = false (this mutation pattern is used elsewhere in the suite too). There's no afterEach or explicit restoration — if resetForTest in beforeEach doesn't reset these values, this can affect downstream tests depending on their ordering. Worth verifying that resetForTest restores config state, or adding explicit cleanup.

Minor: comments are over-verbose

The long block comment before the cleanup chain (18 lines, including the whiteboard analogy) largely duplicates the 7-line comment already present on the IIFE. For production code, a single concise explanation would be easier to maintain. Consider consolidating.

The cleanup chain pattern is correct

void vmCreationPromise
  .catch(() => {})
  .finally(() => { vmCreationPromises.delete(filePath); });

The .catch(() => {}) is necessary to prevent an unhandled rejection warning on the cleanup chain — the actual error still propagates to callers via the returned vmCreationPromise. The void correctly suppresses the floating-promise ESLint rule. This is the right pattern.

export function removeVM(bundlePath: string) {
vmContexts.delete(bundlePath);
vmCreationPromises.delete(bundlePath);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix adding this to removeVM. But resetVM() (just above) still only calls vmContexts.clear() and does not clear vmCreationPromises. The two functions are now inconsistent.

If resetVM() is called while a build is in-flight, the pending promise survives the reset — so the next buildVM() call on the same path returns the stale promise via the has() check, bypassing the reset entirely.

resetVM should also call vmCreationPromises.clear().

resetVM();

const config = getConfig();
config.supportModules = true;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test mutates the shared config object (config.supportModules = true, config.stubTimers = false) without restoring it afterwards. If resetForTest in beforeEach doesn't reset these properties, tests that run after this one and rely on different config values could be affected.

Other tests in this file follow the same pattern (e.g. the Buffer and process in context describe block), so this may already be acceptable given how resetForTest works — but it's worth verifying resetForTest actually restores config state, or adding explicit teardown here.

.catch(() => {})
.finally(() => {
vmCreationPromises.delete(filePath);
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This 18-line comment block largely repeats the 7-line explanation already written just above the IIFE (lines 195–201). The whiteboard analogy is helpful for understanding the concept, but for ongoing maintenance two separate explanations of the same thing will drift out of sync.

Consider consolidating to a single comment at one location — either here or there, not both.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/react-on-rails-pro-node-renderer/src/worker/vm.ts (1)

351-368: Consider shortening this block comment to the technical invariant only.

The analogy is helpful, but this is longer than needed in core runtime code and may be harder to maintain over time.

✂️ Suggested concise replacement
-  // Clean up the map entry after the promise settles (fulfills or rejects).
-  //
-  // Analogy: We write jobs on a whiteboard so nobody starts duplicates. If we
-  // told the helper "erase it when you're done" but the helper failed so fast
-  // they erased it *before we wrote it down*, the failed job would be stuck on
-  // the whiteboard forever — blocking all retries. Instead, we attach a sticky
-  // note to the job saying "erase me when I'm done." The note can't activate
-  // until after we've written the job down, so erasing always happens in the
-  // right order.
-  //
-  // Technically: an async IIFE runs synchronously until its first `await`. If
-  // it throws before any `await` (e.g. `vm.createContext()` or a
-  // `vm.runInContext()` call throwing synchronously before `readFileAsync`),
-  // a try/finally inside the IIFE would run *before* `vmCreationPromises.set()`
-  // (below) has executed — leaving a stale rejected promise in the map that
-  // permanently poisons retries. Chaining .finally() on the promise *after*
-  // set() guarantees cleanup runs as a microtask after the current synchronous
-  // execution, so set() has always run first.
+  // Always clear `vmCreationPromises[filePath]` after this build settles.
+  // The cleanup is attached after `set()` so synchronous pre-`await` throws
+  // cannot leave a stale rejected promise that blocks retries.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro-node-renderer/src/worker/vm.ts` around lines 351
- 368, Reduce the long explanatory analogy in the vm.ts comment to a concise
technical invariant: explain only that synchronous throws (e.g., from
vm.createContext() or vm.runInContext() before awaiting readFileAsync) can leave
a stale rejected entry unless cleanup is chained after vmCreationPromises.set(),
and that chaining .finally() on the stored promise ensures cleanup runs as a
microtask after set() has executed; keep references to vmCreationPromises,
vm.createContext(), vm.runInContext(), readFileAsync, and the .finally() cleanup
behavior so the intent is unambiguous.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react-on-rails-pro-node-renderer/src/worker/vm.ts`:
- Around line 351-368: Reduce the long explanatory analogy in the vm.ts comment
to a concise technical invariant: explain only that synchronous throws (e.g.,
from vm.createContext() or vm.runInContext() before awaiting readFileAsync) can
leave a stale rejected entry unless cleanup is chained after
vmCreationPromises.set(), and that chaining .finally() on the stored promise
ensures cleanup runs as a microtask after set() has executed; keep references to
vmCreationPromises, vm.createContext(), vm.runInContext(), readFileAsync, and
the .finally() cleanup behavior so the intent is unambiguous.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fef7fba and ee5bfbb.

📒 Files selected for processing (2)
  • packages/react-on-rails-pro-node-renderer/src/worker/vm.ts
  • packages/react-on-rails-pro-node-renderer/tests/vm.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-on-rails-pro-node-renderer/tests/vm.test.ts

resetVM() only cleared vmContexts but not vmCreationPromises. If
resetVM() was called while a VM creation was in-flight, the stale
promise would remain in vmCreationPromises, causing subsequent
buildVM() calls to return the old (possibly rejected) promise.

This matches the cleanup already done in removeVM().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 27, 2026

Review: Fix buildVM promise cleanup ordering

The fix is correct and the root cause analysis is accurate. Well done.

Core Fix Assessment

The problem is real: an async IIFE that throws synchronously (before its first await) has its finally block run before vmCreationPromises.set() — leaving a rejected promise permanently in the map and blocking all retries. Moving cleanup to .finally() chained after set() solves this cleanly.

What's Good

  • Root cause is precisely identified — the asymmetry between sync throw timing and set() is subtle but the PR explains it correctly
  • resetVM() fix is important — the previous version didn't clear vmCreationPromises, which could leave stale in-flight promise entries across tests or resets
  • removeVM() fix is consistent — now clears both maps atomically, which is the right behavior
  • Test targets the exact failure mode — mocking vm.createContext to throw synchronously directly exercises the pre-await scenario

Minor Issues

1. .catch(() => {}) is load-bearing but the reason isn't commented

The .catch(() => {}) before .finally() is not just style — without it, if vmCreationPromise rejects, Node.js would flag the cleanup chain itself as an unhandled rejection (separate from the original rejected promise returned to callers). The comment block explains the ordering guarantee but not why the .catch is there. Someone doing cleanup later might remove it thinking it's redundant.

2. .finally() fires on success too (intentional, but worth being explicit)

The cleanup removes the vmCreationPromises entry regardless of success or failure. On success this is correct — future buildVM calls use the vmContexts early-return path, not vmCreationPromises. But the comment says "Clean up the map entry after the promise settles" without noting this deliberate design: vmCreationPromises only tracks in-flight builds, not completed ones.

3. Comment verbosity

The whiteboard analogy (9 lines) followed by the technical explanation (7 lines) in the same comment block is longer than necessary. The technical "Technically:" paragraph is sufficient on its own.

4. Test doesn't restore config on failure path

The new test mutates getConfig() directly (config.supportModules = true) without a try/finally to restore config if the test fails unexpectedly. This is fine in practice since the parent beforeEach calls resetForTest()setConfig() before each test, but it's worth noting for consistency with how other tests in this suite are structured.

Verdict

Approve with suggestions. The bug fix is correct, the test directly validates the fixed scenario, and the resetVM/removeVM improvements are solid. The minor points above are non-blocking.

// set() guarantees cleanup runs as a microtask after the current synchronous
// execution, so set() has always run first.
void vmCreationPromise
.catch(() => {})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .catch(() => {}) here is load-bearing and its purpose should be documented. Without it, .finally() on the rejected vmCreationPromise would produce a second rejected promise on this cleanup chain — a separate unhandled rejection from the one already returned to callers via return vmCreationPromise. Adding .catch(() => {}) first swallows the rejection on this side-chain only, while the caller still receives the original rejected promise. Consider adding a brief comment:

Suggested change
.catch(() => {})
// .catch() here prevents Node.js from flagging the cleanup chain itself as an
// unhandled rejection. The original rejection still propagates to callers via
// `return vmCreationPromise` below.
void vmCreationPromise
.catch(() => {})
.finally(() => {
vmCreationPromises.delete(filePath);
});

// Store the promise
// Store the promise BEFORE any async work completes, so concurrent callers
// find it via the has() check above.
vmCreationPromises.set(filePath, vmCreationPromise);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting explicitly that the .finally() also fires on success, not just failure — and that is intentional. vmCreationPromises only tracks in-flight builds. On success, once the promise resolves, this entry is cleaned up and future buildVM calls for the same path take the vmContexts.get() early-return path (line ~188) instead. A short note here would help future readers understand this is by design and not a bug:

Suggested change
vmCreationPromises.set(filePath, vmCreationPromise);
// Store the promise BEFORE any async work completes, so concurrent callers
// find it via the has() check above.
vmCreationPromises.set(filePath, vmCreationPromise);
// vmCreationPromises only tracks *in-flight* builds; completed (or failed)
// builds are cleaned up here. On success, future callers use the vmContexts
// early-return path above. On failure, cleanup unblocks retries.

});

// First call fails synchronously during vm.createContext()
await expect(buildVM(serverBundlePath)).rejects.toThrow('sync context creation failure');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timing here works correctly, but it's worth understanding why. When buildVM() throws synchronously inside the IIFE, vmCreationPromise is already a rejected promise by the time .finally() is scheduled. The await on this line drains the microtask queue — which includes the .finally() cleanup callback — before proceeding to the retry. So by the time line 572 executes, vmCreationPromises is already clean. This is subtle but correct; no explicit microtask flush is needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:16.4.0-must-have Must-have for 16.4.0: critical bug/perf/usability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Race condition in node renderer buildVM() causes intermittent "ReactOnRails value exists" error

2 participants