Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions packages/react-on-rails-pro-node-renderer/src/worker/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,13 @@ export async function buildVM(filePath: string) {
return Promise.resolve(true);
}

// Create a new promise for this VM creation
// Create the VM creation promise. The IIFE runs synchronously until its first
// `await`, so we must store it in the map immediately after creation — before
// the microtask queue is drained — to prevent concurrent callers from starting
// a duplicate build. Cleanup uses `.finally()` on the stored promise rather
// than a try/finally inside the IIFE, because an IIFE's finally block can
// execute synchronously (before `vmCreationPromises.set`) when the code throws
// before the first `await`, which would leave a stale rejected promise in the map.
const vmCreationPromise = (async () => {
try {
const { supportModules, stubTimers, additionalContext } = getConfig();
Expand Down Expand Up @@ -335,22 +341,44 @@ export async function buildVM(filePath: string) {
log.error({ error }, 'Caught Error when creating context in buildVM');
errorReporter.error(error as Error);
throw error;
} finally {
// Always remove the promise from the map when done
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.


// 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.
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);
});

.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.


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);
}

/** @internal Used in tests */
export function resetVM() {
// Clear all VM contexts
vmContexts.clear();
vmCreationPromises.clear();
}

// Optional: Add a method to remove a specific VM if needed
Expand All @@ -359,4 +387,5 @@ export function resetVM() {
*/
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().

31 changes: 31 additions & 0 deletions packages/react-on-rails-pro-node-renderer/tests/vm.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from 'path';
import vm from 'vm';
import {
uploadedBundlePath,
createUploadedBundle,
Expand Down Expand Up @@ -542,6 +543,36 @@ describe('buildVM and runInVM', () => {
expect(vmContext1).toBe(vmContext2);
});

test('buildVM recovers after synchronous throw before first await', async () => {
// Clear any cached VM for serverBundlePath from prior tests
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.

config.stubTimers = false;

// Mock vm.createContext to throw synchronously. This simulates a
// failure BEFORE the first `await` in the buildVM IIFE — the exact
// scenario the .finally() cleanup ordering was designed to handle.
// With the old code (try/finally inside the IIFE), a synchronous
// throw would run cleanup before vmCreationPromises.set(), leaving
// a stale rejected promise that permanently blocks retries.
const createContextSpy = jest.spyOn(vm, 'createContext').mockImplementationOnce(() => {
throw new Error('sync context creation failure');
});

// 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.


// Restore vm.createContext before retrying
createContextSpy.mockRestore();

// Retry the SAME path — if vmCreationPromises wasn't cleaned up,
// this would return the stale rejected promise and fail
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.

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


test('running runInVM before buildVM', async () => {
resetVM();
void prepareVM(true);
Expand Down
Loading