-
-
Notifications
You must be signed in to change notification settings - Fork 629
Fix buildVM promise cleanup ordering (#2483) #2484
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
caf0e0b
f585f1c
fef7fba
ee5bfbb
357e390
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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(); | ||||||||||||||||||||
|
|
@@ -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); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // 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(() => {}) | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The .catch(() => {}) purpose is already documented in the block comment above (lines 366-368): 'Chaining .finally() on the promise after set() guarantees cleanup runs as a microtask after the current synchronous execution.' But adding a brief inline comment for the .catch specifically is a good idea — the block comment explains the ordering but not why .catch is needed to prevent unhandled rejection on the cleanup chain. Will leave as-is for now since the behavior is covered, but open to adding an inline comment if the reviewer feels strongly. |
||||||||||||||||||||
| .finally(() => { | ||||||||||||||||||||
| vmCreationPromises.delete(filePath); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keeping both comments intentionally — the IIFE comment (lines 195-201) explains the design decision at point of use, while the cleanup comment (lines 351-368) explains the mechanism at point of cleanup. They serve different audiences: someone reading the IIFE vs someone reading the cleanup chain. The whiteboard analogy makes the subtle ordering issue approachable for future maintainers. |
||||||||||||||||||||
|
|
||||||||||||||||||||
| return vmCreationPromise; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
export function removeVM(bundlePath: string) {
vmContexts.delete(bundlePath);
vmCreationPromises.delete(bundlePath);
}
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. removeVM already clears vmCreationPromises — see line 390. This was part of the original fix in caf0e0b. |
||||||||||||||||||||
| /** @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 | ||||||||||||||||||||
|
|
@@ -359,4 +387,5 @@ export function resetVM() { | |||||||||||||||||||
| */ | ||||||||||||||||||||
| export function removeVM(bundlePath: string) { | ||||||||||||||||||||
| vmContexts.delete(bundlePath); | ||||||||||||||||||||
| vmCreationPromises.delete(bundlePath); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good fix adding this to If
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 357e390 — resetVM() now calls vmCreationPromises.clear() alongside vmContexts.clear(). |
||||||||||||||||||||
| 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, | ||
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test mutates the shared config object ( Other tests in this file follow the same pattern (e.g. the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No change needed — resetForTest() calls setConfig(testName) which rebuilds the config object from scratch. Each beforeEach gets a fresh config. This is the same pattern used by all other tests in this suite (e.g., the Buffer/process and VM Pool Management describe blocks). |
||
| 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'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The timing here works correctly, but it's worth understanding why. When
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the analysis — this confirms the implementation is correct. The await on line 565 drains the microtask queue (including the .finally() cleanup), so vmCreationPromises is clean by the time the retry on line 572 executes. |
||
|
|
||
| // 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This recovery call uses a different path ( Consider either retrying the same path (after creating the file or restoring a mock) or at least adding a direct assertion that
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in ee5bfbb — the test now retries with the same path after restoring vm.createContext. |
||
| expect(hasVMContextForBundle(serverBundlePath)).toBeTruthy(); | ||
| }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test doesn't validate the cleanup ordering fixMedium Severity This test doesn't actually exercise the fix it claims to verify. The failure path uses
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in ee5bfbb — the test now triggers a synchronous throw via vm.createContext mock (before the first await), then retries the same path. |
||
|
|
||
| test('running runInVM before buildVM', async () => { | ||
| resetVM(); | ||
| void prepareVM(true); | ||
|
|
||


There was a problem hiding this comment.
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.vmCreationPromisesonly tracks in-flight builds. On success, once the promise resolves, this entry is cleaned up and futurebuildVMcalls for the same path take thevmContexts.get()early-return path (line ~188) instead. A short note here would help future readers understand this is by design and not a bug:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. The IIFE comment (line 195) already says 'Store the promise BEFORE any async work completes, so concurrent callers find it via the has() check above' and the cleanup comment says 'Clean up the map entry after the promise settles (fulfills or rejects)' — so the success-path cleanup is documented. The success path works because completed builds are cached in vmContexts, so future callers take the vmContexts.get() early-return.