Skip to content

Commit e8de740

Browse files
Refactor VM handling and introduce ExecutionContext
- Replaced the `runInVM` function with a new `ExecutionContext` class to manage VM contexts more effectively. - Updated the `handleRenderRequest` function to utilize the new `ExecutionContext`, improving the handling of rendering requests. - Enhanced error management by introducing `VMContextNotFoundError` for better clarity when VM contexts are missing. - Refactored tests to align with the new execution context structure, ensuring consistent behavior across rendering scenarios.
1 parent 81ec0d6 commit e8de740

File tree

6 files changed

+232
-151
lines changed

6 files changed

+232
-151
lines changed

react_on_rails_pro/packages/node-renderer/src/worker/handleRenderRequest.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
} from '../shared/utils';
2828
import { getConfig } from '../shared/configBuilder';
2929
import * as errorReporter from '../shared/errorReporter';
30-
import { buildVM, hasVMContextForBundle, runInVM } from './vm';
30+
import { buildExecutionContext, ExecutionContext, VMContextNotFoundError } from './vm';
3131

3232
export type ProvidedNewBundle = {
3333
timestamp: string | number;
@@ -37,9 +37,10 @@ export type ProvidedNewBundle = {
3737
async function prepareResult(
3838
renderingRequest: string,
3939
bundleFilePathPerTimestamp: string,
40+
executionContext: ExecutionContext,
4041
): Promise<ResponseResult> {
4142
try {
42-
const result = await runInVM(renderingRequest, bundleFilePathPerTimestamp, cluster);
43+
const result = await executionContext.runInVM(renderingRequest, bundleFilePathPerTimestamp, cluster);
4344

4445
let exceptionMessage = null;
4546
if (!result) {
@@ -209,9 +210,15 @@ export async function handleRenderRequest({
209210
};
210211
}
211212

212-
// If the current VM has the correct bundle and is ready
213-
if (allBundleFilePaths.every((bundleFilePath) => hasVMContextForBundle(bundleFilePath))) {
214-
return await prepareResult(renderingRequest, entryBundleFilePath);
213+
try {
214+
const executionContext = await buildExecutionContext(allBundleFilePaths, /* buildVmsIfNeeded */ false);
215+
return await prepareResult(renderingRequest, entryBundleFilePath, executionContext);
216+
} catch (e) {
217+
// Ignore VMContextNotFoundError, it means the bundle does not exist.
218+
// The following code will handle this case.
219+
if (!(e instanceof VMContextNotFoundError)) {
220+
throw e;
221+
}
215222
}
216223

217224
// If gem has posted updated bundle:
@@ -230,10 +237,13 @@ export async function handleRenderRequest({
230237

231238
// The bundle exists, but the VM has not yet been created.
232239
// Another worker must have written it or it was saved during deployment.
233-
log.info('Bundle %s exists. Building VM for worker %s.', entryBundleFilePath, workerIdLabel());
234-
await Promise.all(allBundleFilePaths.map((bundleFilePath) => buildVM(bundleFilePath)));
235-
236-
return await prepareResult(renderingRequest, entryBundleFilePath);
240+
log.info(
241+
'Bundle %s exists. Building ExecutionContext for worker %s.',
242+
entryBundleFilePath,
243+
workerIdLabel(),
244+
);
245+
const executionContext = await buildExecutionContext(allBundleFilePaths, /* buildVmsIfNeeded */ true);
246+
return await prepareResult(renderingRequest, entryBundleFilePath, executionContext);
237247
} catch (error) {
238248
const msg = formatExceptionMessage(
239249
renderingRequest,

react_on_rails_pro/packages/node-renderer/src/worker/vm.ts

Lines changed: 121 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import * as errorReporter from '../shared/errorReporter';
2929
const readFileAsync = promisify(fs.readFile);
3030
const writeFileAsync = promisify(fs.writeFile);
3131

32-
interface VMContext {
32+
export interface VMContext {
3333
context: Context;
3434
sharedConsoleHistory: SharedConsoleHistory;
3535
lastUsed: number; // Track when this VM was last used
@@ -101,84 +101,14 @@ function manageVMPoolSize() {
101101
}
102102
}
103103

104-
/**
105-
*
106-
* @param renderingRequest JS Code to execute for SSR
107-
* @param filePath
108-
* @param vmCluster
109-
*/
110-
export async function runInVM(
111-
renderingRequest: string,
112-
filePath: string,
113-
vmCluster?: typeof cluster,
114-
): Promise<RenderResult> {
115-
const { serverBundleCachePath } = getConfig();
116-
117-
try {
118-
// Wait for VM creation if it's in progress
119-
if (vmCreationPromises.has(filePath)) {
120-
await vmCreationPromises.get(filePath);
121-
}
122-
123-
// Get the correct VM context based on the provided bundle path
124-
const vmContext = getVMContext(filePath);
125-
126-
if (!vmContext) {
127-
throw new Error(`No VM context found for bundle ${filePath}`);
128-
}
129-
130-
// Update last used timestamp
131-
vmContext.lastUsed = Date.now();
132-
133-
const { context, sharedConsoleHistory } = vmContext;
134-
135-
if (log.level === 'debug') {
136-
// worker is nullable in the primary process
137-
const workerId = vmCluster?.worker?.id;
138-
log.debug(`worker ${workerId ? `${workerId} ` : ''}received render request for bundle ${filePath} with code
139-
${smartTrim(renderingRequest)}`);
140-
const debugOutputPathCode = path.join(serverBundleCachePath, 'code.js');
141-
log.debug(`Full code executed written to: ${debugOutputPathCode}`);
142-
await writeFileAsync(debugOutputPathCode, renderingRequest);
143-
}
144-
145-
let result = sharedConsoleHistory.trackConsoleHistoryInRenderRequest(() => {
146-
context.renderingRequest = renderingRequest;
147-
try {
148-
return vm.runInContext(renderingRequest, context) as RenderCodeResult;
149-
} finally {
150-
context.renderingRequest = undefined;
151-
}
152-
});
153-
154-
if (isReadableStream(result)) {
155-
const newStreamAfterHandlingError = handleStreamError(result, (error) => {
156-
const msg = formatExceptionMessage(renderingRequest, error, 'Error in a rendering stream');
157-
errorReporter.message(msg);
158-
});
159-
return newStreamAfterHandlingError;
160-
}
161-
if (typeof result !== 'string') {
162-
const objectResult = await result;
163-
result = JSON.stringify(objectResult);
164-
}
165-
if (log.level === 'debug') {
166-
log.debug(`result from JS:
167-
${smartTrim(result)}`);
168-
const debugOutputPathResult = path.join(serverBundleCachePath, 'result.json');
169-
log.debug(`Wrote result to file: ${debugOutputPathResult}`);
170-
await writeFileAsync(debugOutputPathResult, result);
171-
}
172-
173-
return result;
174-
} catch (exception) {
175-
const exceptionMessage = formatExceptionMessage(renderingRequest, exception);
176-
log.debug('Caught exception in rendering request', exceptionMessage);
177-
return Promise.resolve({ exceptionMessage });
104+
export class VMContextNotFoundError extends Error {
105+
constructor(bundleFilePath: string) {
106+
super(`VMContext not found for bundle: ${bundleFilePath}`);
107+
this.name = 'VMContextNotFoundError';
178108
}
179109
}
180110

181-
export async function buildVM(filePath: string): Promise<VMContext> {
111+
async function buildVM(filePath: string): Promise<VMContext> {
182112
// Return existing promise if VM is already being created
183113
if (vmCreationPromises.has(filePath)) {
184114
return vmCreationPromises.get(filePath) as Promise<VMContext>;
@@ -200,12 +130,7 @@ export async function buildVM(filePath: string): Promise<VMContext> {
200130
additionalContext !== null && additionalContext.constructor === Object;
201131
const sharedConsoleHistory = new SharedConsoleHistory();
202132

203-
const runOnOtherBundle = async (bundleTimestamp: string | number, renderingRequest: string) => {
204-
const bundlePath = getRequestBundleFilePath(bundleTimestamp);
205-
return runInVM(renderingRequest, bundlePath, cluster);
206-
};
207-
208-
const contextObject = { sharedConsoleHistory, runOnOtherBundle };
133+
const contextObject = { sharedConsoleHistory };
209134

210135
if (supportModules) {
211136
// IMPORTANT: When adding anything to this object, update:
@@ -349,6 +274,120 @@ export async function buildVM(filePath: string): Promise<VMContext> {
349274
return vmCreationPromise;
350275
}
351276

277+
async function getOrBuildVMContext(bundleFilePath: string, buildVmsIfNeeded: boolean): Promise<VMContext> {
278+
const vmContext = getVMContext(bundleFilePath);
279+
if (vmContext) {
280+
return vmContext;
281+
}
282+
283+
const vmCreationPromise = vmCreationPromises.get(bundleFilePath);
284+
if (vmCreationPromise) {
285+
return vmCreationPromise;
286+
}
287+
288+
if (buildVmsIfNeeded) {
289+
return buildVM(bundleFilePath);
290+
}
291+
292+
throw new VMContextNotFoundError(bundleFilePath);
293+
}
294+
295+
export type ExecutionContext = {
296+
runInVM: (
297+
renderingRequest: string,
298+
bundleFilePath: string,
299+
vmCluster?: typeof cluster,
300+
) => Promise<RenderResult>;
301+
getVMContext: (bundleFilePath: string) => VMContext | undefined;
302+
};
303+
304+
export async function buildExecutionContext(
305+
bundlePaths: string[],
306+
buildVmsIfNeeded: boolean,
307+
): Promise<ExecutionContext> {
308+
const mapBundleFilePathToVMContext = new Map<string, VMContext>();
309+
await Promise.all(
310+
bundlePaths.map(async (bundleFilePath) => {
311+
const vmContext = await getOrBuildVMContext(bundleFilePath, buildVmsIfNeeded);
312+
vmContext.lastUsed = Date.now();
313+
mapBundleFilePathToVMContext.set(bundleFilePath, vmContext);
314+
}),
315+
);
316+
const sharedExecutionContext = new Map();
317+
318+
const runInVM = async (renderingRequest: string, bundleFilePath: string, vmCluster?: typeof cluster) => {
319+
try {
320+
const { serverBundleCachePath } = getConfig();
321+
const vmContext = mapBundleFilePathToVMContext.get(bundleFilePath);
322+
if (!vmContext) {
323+
throw new VMContextNotFoundError(bundleFilePath);
324+
}
325+
326+
// Update last used timestamp
327+
vmContext.lastUsed = Date.now();
328+
329+
const { context, sharedConsoleHistory } = vmContext;
330+
331+
if (log.level === 'debug') {
332+
// worker is nullable in the primary process
333+
const workerId = vmCluster?.worker?.id;
334+
log.debug(`worker ${workerId ? `${workerId} ` : ''}received render request for bundle ${bundleFilePath} with code
335+
${smartTrim(renderingRequest)}`);
336+
const debugOutputPathCode = path.join(serverBundleCachePath, 'code.js');
337+
log.debug(`Full code executed written to: ${debugOutputPathCode}`);
338+
await writeFileAsync(debugOutputPathCode, renderingRequest);
339+
}
340+
341+
let result = sharedConsoleHistory.trackConsoleHistoryInRenderRequest(() => {
342+
context.renderingRequest = renderingRequest;
343+
context.sharedExecutionContext = sharedExecutionContext;
344+
context.runOnOtherBundle = (bundleTimestamp: string | number, newRenderingRequest: string) => {
345+
const otherBundleFilePath = getRequestBundleFilePath(bundleTimestamp);
346+
return runInVM(otherBundleFilePath, newRenderingRequest, vmCluster);
347+
};
348+
349+
try {
350+
return vm.runInContext(renderingRequest, context) as RenderCodeResult;
351+
} finally {
352+
context.renderingRequest = undefined;
353+
context.sharedExecutionContext = undefined;
354+
context.runOnOtherBundle = undefined;
355+
}
356+
});
357+
358+
if (isReadableStream(result)) {
359+
const newStreamAfterHandlingError = handleStreamError(result, (error) => {
360+
const msg = formatExceptionMessage(renderingRequest, error, 'Error in a rendering stream');
361+
errorReporter.message(msg);
362+
});
363+
return newStreamAfterHandlingError;
364+
}
365+
if (typeof result !== 'string') {
366+
const objectResult = await result;
367+
result = JSON.stringify(objectResult);
368+
}
369+
if (log.level === 'debug') {
370+
log.debug(`result from JS:
371+
${smartTrim(result)}`);
372+
const debugOutputPathResult = path.join(serverBundleCachePath, 'result.json');
373+
log.debug(`Wrote result to file: ${debugOutputPathResult}`);
374+
await writeFileAsync(debugOutputPathResult, result);
375+
}
376+
377+
return result;
378+
} catch (exception) {
379+
const exceptionMessage = formatExceptionMessage(renderingRequest, exception);
380+
log.debug('Caught exception in rendering request', exceptionMessage);
381+
return Promise.resolve({ exceptionMessage });
382+
}
383+
};
384+
385+
return {
386+
getVMContext: (bundleFilePath: string) => mapBundleFilePathToVMContext.get(bundleFilePath),
387+
runInVM,
388+
};
389+
}
390+
352391
export function resetVM() {
353392
// Clear all VM contexts
354393
vmContexts.clear();

react_on_rails_pro/packages/node-renderer/tests/helper.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from 'path';
44
import fsPromises from 'fs/promises';
55
import fs from 'fs';
66
import fsExtra from 'fs-extra';
7-
import { buildVM, resetVM } from '../src/worker/vm';
7+
import { buildExecutionContext, resetVM } from '../src/worker/vm';
88
import { buildConfig } from '../src/shared/configBuilder';
99

1010
export const mkdirAsync = fsPromises.mkdir;
@@ -59,12 +59,12 @@ export function vmSecondaryBundlePath(testName: string) {
5959

6060
export async function createVmBundle(testName: string) {
6161
await safeCopyFileAsync(getFixtureBundle(), vmBundlePath(testName));
62-
await buildVM(vmBundlePath(testName));
62+
await buildExecutionContext([vmBundlePath(testName)], /* buildVmsIfNeeded */ true);
6363
}
6464

6565
export async function createSecondaryVmBundle(testName: string) {
6666
await safeCopyFileAsync(getFixtureSecondaryBundle(), vmSecondaryBundlePath(testName));
67-
await buildVM(vmSecondaryBundlePath(testName));
67+
await buildExecutionContext([vmSecondaryBundlePath(testName)], /* buildVmsIfNeeded */ true);
6868
}
6969

7070
export function lockfilePath(testName: string) {

react_on_rails_pro/packages/node-renderer/tests/serverRenderRSCReactComponent.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import path from 'path';
22
import fs from 'fs';
33
import { Readable } from 'stream';
4-
import { buildVM, getVMContext, resetVM } from '../src/worker/vm';
4+
import { buildExecutionContext, resetVM } from '../src/worker/vm';
55
import { getConfig } from '../src/shared/configBuilder';
66

77
const SimpleWorkingComponent = () => 'hello';
@@ -62,8 +62,8 @@ describe('serverRenderRSCReactComponent', () => {
6262
// The serverRenderRSCReactComponent function should only be called when the bundle is compiled with the `react-server` condition.
6363
// Therefore, we cannot call it directly in the test files. Instead, we run the RSC bundle through the VM and call the method from there.
6464
const getReactOnRailsRSCObject = async () => {
65-
// Use the copied rsc-bundle.js file from temp directory
66-
const vmContext = await buildVM(tempRscBundlePath);
65+
const executionContext = await buildExecutionContext([tempRscBundlePath], /* buildVmsIfNeeded */ true);
66+
const vmContext = executionContext.getVMContext(tempRscBundlePath);
6767
const { ReactOnRails, React } = vmContext.context;
6868

6969
function SuspensedComponentWithAsyncError() {

0 commit comments

Comments
 (0)