react-on-rails-pro-node-renderer 16.6.0-rc.0 → 16.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/master.d.ts.map +1 -1
- package/lib/master.js +56 -4
- package/lib/master.js.map +1 -1
- package/lib/shared/configBuilder.d.ts.map +1 -1
- package/lib/shared/configBuilder.js +15 -0
- package/lib/shared/configBuilder.js.map +1 -1
- package/lib/shared/utils.d.ts +9 -2
- package/lib/shared/utils.d.ts.map +1 -1
- package/lib/shared/utils.js +15 -8
- package/lib/shared/utils.js.map +1 -1
- package/lib/shared/workerMessages.d.ts +13 -0
- package/lib/shared/workerMessages.d.ts.map +1 -0
- package/lib/shared/workerMessages.js +23 -0
- package/lib/shared/workerMessages.js.map +1 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/worker/handleRenderRequest.d.ts +2 -2
- package/lib/worker/handleRenderRequest.d.ts.map +1 -1
- package/lib/worker/handleRenderRequest.js +5 -5
- package/lib/worker/handleRenderRequest.js.map +1 -1
- package/lib/worker/startupErrorHandler.d.ts +10 -0
- package/lib/worker/startupErrorHandler.d.ts.map +1 -0
- package/lib/worker/startupErrorHandler.js +57 -0
- package/lib/worker/startupErrorHandler.js.map +1 -0
- package/lib/worker/vm.js +2 -2
- package/lib/worker/vm.js.map +1 -1
- package/lib/worker.d.ts.map +1 -1
- package/lib/worker.js +73 -9
- package/lib/worker.js.map +1 -1
- package/package.json +2 -2
- package/src/master.ts +64 -4
- package/src/shared/configBuilder.ts +18 -0
- package/src/shared/utils.ts +18 -8
- package/src/shared/workerMessages.ts +34 -0
- package/src/worker/handleRenderRequest.ts +18 -7
- package/src/worker/startupErrorHandler.ts +65 -0
- package/src/worker/vm.ts +2 -2
- package/src/worker.ts +86 -15
- package/tests/configBuilder.test.ts +34 -0
- package/tests/masterStartupFailure.test.ts +295 -0
- package/tests/worker.test.ts +148 -0
- package/tests/workerStartupFailure.test.ts +250 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { WORKER_STARTUP_FAILURE, type WorkerStartupFailureMessage } from '../src/shared/workerMessages';
|
|
2
|
+
|
|
3
|
+
type MockWorker = {
|
|
4
|
+
id: number;
|
|
5
|
+
isScheduledRestart?: boolean;
|
|
6
|
+
process: { exitCode: number | null };
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type ClusterHandlers = {
|
|
10
|
+
message: (worker: MockWorker, message: unknown) => void;
|
|
11
|
+
exit: (worker: MockWorker) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type MockCluster = {
|
|
15
|
+
on: jest.Mock<MockCluster, [event: string, handler: (...args: unknown[]) => void]>;
|
|
16
|
+
fork: jest.Mock<unknown, []>;
|
|
17
|
+
disconnect: jest.Mock<void, [callback?: () => void]>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function buildStartupFailureMessage(
|
|
21
|
+
overrides: Partial<WorkerStartupFailureMessage> = {},
|
|
22
|
+
): WorkerStartupFailureMessage {
|
|
23
|
+
return {
|
|
24
|
+
type: WORKER_STARTUP_FAILURE,
|
|
25
|
+
stage: 'listen',
|
|
26
|
+
code: 'EADDRINUSE',
|
|
27
|
+
errno: -48,
|
|
28
|
+
syscall: 'listen',
|
|
29
|
+
host: 'localhost',
|
|
30
|
+
port: 3800,
|
|
31
|
+
message: 'listen EADDRINUSE: address already in use :::3800',
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function setupMasterRunHarness() {
|
|
37
|
+
const operations: string[] = [];
|
|
38
|
+
const clusterHandlers: Partial<ClusterHandlers> = {};
|
|
39
|
+
const mockFork = jest.fn(() => {
|
|
40
|
+
operations.push('fork');
|
|
41
|
+
return {};
|
|
42
|
+
});
|
|
43
|
+
const mockCluster = {} as MockCluster;
|
|
44
|
+
mockCluster.disconnect = jest.fn((callback?: () => void) => {
|
|
45
|
+
if (callback) callback();
|
|
46
|
+
});
|
|
47
|
+
mockCluster.on = jest.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
48
|
+
operations.push(`on:${event}`);
|
|
49
|
+
if (event === 'message') {
|
|
50
|
+
clusterHandlers.message = handler as ClusterHandlers['message'];
|
|
51
|
+
} else if (event === 'exit') {
|
|
52
|
+
clusterHandlers.exit = handler as ClusterHandlers['exit'];
|
|
53
|
+
}
|
|
54
|
+
return mockCluster;
|
|
55
|
+
});
|
|
56
|
+
mockCluster.fork = mockFork;
|
|
57
|
+
const mockErrorReporterMessage = jest.fn();
|
|
58
|
+
const mockLog = {
|
|
59
|
+
info: jest.fn(),
|
|
60
|
+
warn: jest.fn(),
|
|
61
|
+
error: jest.fn(),
|
|
62
|
+
fatal: jest.fn(),
|
|
63
|
+
};
|
|
64
|
+
const mockBuildConfig = jest.fn(() => ({
|
|
65
|
+
workersCount: 2,
|
|
66
|
+
allWorkersRestartInterval: undefined,
|
|
67
|
+
delayBetweenIndividualWorkerRestarts: undefined,
|
|
68
|
+
gracefulWorkerRestartTimeout: 0,
|
|
69
|
+
serverBundleCachePath: '/tmp/react-on-rails-pro-node-renderer-bundles',
|
|
70
|
+
}));
|
|
71
|
+
const mockLogSanitizedConfig = jest.fn();
|
|
72
|
+
const mockGetLicenseStatus = jest.fn(() => 'valid');
|
|
73
|
+
const setIntervalSpy = jest.spyOn(global, 'setInterval').mockReturnValue(0 as unknown as NodeJS.Timeout);
|
|
74
|
+
const setTimeoutSpy = jest
|
|
75
|
+
.spyOn(global, 'setTimeout')
|
|
76
|
+
.mockReturnValue({ unref: jest.fn() } as unknown as NodeJS.Timeout);
|
|
77
|
+
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
78
|
+
throw new Error(`process.exit:${code}`);
|
|
79
|
+
}) as typeof process.exit);
|
|
80
|
+
|
|
81
|
+
jest.doMock('cluster', () => ({
|
|
82
|
+
__esModule: true,
|
|
83
|
+
default: mockCluster,
|
|
84
|
+
}));
|
|
85
|
+
jest.doMock('../src/shared/log', () => ({
|
|
86
|
+
__esModule: true,
|
|
87
|
+
default: mockLog,
|
|
88
|
+
}));
|
|
89
|
+
jest.doMock('../src/shared/errorReporter', () => ({
|
|
90
|
+
__esModule: true,
|
|
91
|
+
message: mockErrorReporterMessage,
|
|
92
|
+
error: jest.fn(),
|
|
93
|
+
addMessageNotifier: jest.fn(),
|
|
94
|
+
addErrorNotifier: jest.fn(),
|
|
95
|
+
addNotifier: jest.fn(),
|
|
96
|
+
}));
|
|
97
|
+
jest.doMock('../src/shared/configBuilder', () => ({
|
|
98
|
+
__esModule: true,
|
|
99
|
+
buildConfig: mockBuildConfig,
|
|
100
|
+
logSanitizedConfig: mockLogSanitizedConfig,
|
|
101
|
+
}));
|
|
102
|
+
jest.doMock('../src/shared/licenseValidator', () => ({
|
|
103
|
+
__esModule: true,
|
|
104
|
+
getLicenseStatus: mockGetLicenseStatus,
|
|
105
|
+
}));
|
|
106
|
+
jest.doMock('../src/master/restartWorkers', () => ({
|
|
107
|
+
__esModule: true,
|
|
108
|
+
default: jest.fn(),
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
let masterRun: typeof import('../src/master').default | undefined;
|
|
112
|
+
jest.isolateModules(() => {
|
|
113
|
+
// eslint-disable-next-line global-require
|
|
114
|
+
masterRun = require('../src/master').default as typeof import('../src/master').default;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!masterRun) {
|
|
118
|
+
throw new Error('Failed to load masterRun');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
masterRun();
|
|
122
|
+
|
|
123
|
+
if (!clusterHandlers.message || !clusterHandlers.exit) {
|
|
124
|
+
throw new Error('Failed to register cluster handlers');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
operations,
|
|
129
|
+
clusterHandlers: clusterHandlers as ClusterHandlers,
|
|
130
|
+
mockFork,
|
|
131
|
+
mockCluster,
|
|
132
|
+
mockErrorReporterMessage,
|
|
133
|
+
setIntervalSpy,
|
|
134
|
+
setTimeoutSpy,
|
|
135
|
+
processExitSpy,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function waitForSetImmediate() {
|
|
140
|
+
await new Promise<void>((resolve) => {
|
|
141
|
+
setImmediate(resolve);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
describe('master startup failure handling via masterRun wiring', () => {
|
|
146
|
+
afterEach(() => {
|
|
147
|
+
jest.restoreAllMocks();
|
|
148
|
+
jest.resetModules();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it.each([
|
|
152
|
+
{
|
|
153
|
+
testName: 'EADDRINUSE startup failure',
|
|
154
|
+
failureWorker: { id: 1, process: { exitCode: 1 } },
|
|
155
|
+
exitingWorker: { id: 1, process: { exitCode: 1 } },
|
|
156
|
+
failure: buildStartupFailureMessage(),
|
|
157
|
+
expectedMessage: 'Node renderer startup failed: localhost:3800 is already in use',
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
testName: 'generic startup failure from one worker while another exits first',
|
|
161
|
+
failureWorker: { id: 2, process: { exitCode: 1 } },
|
|
162
|
+
exitingWorker: { id: 1, process: { exitCode: 1 } },
|
|
163
|
+
failure: buildStartupFailureMessage({
|
|
164
|
+
code: 'EACCES',
|
|
165
|
+
message: 'listen EACCES: permission denied 0.0.0.0:80',
|
|
166
|
+
}),
|
|
167
|
+
expectedMessage:
|
|
168
|
+
'Node renderer startup failed in worker 2: listen EACCES: permission denied 0.0.0.0:80',
|
|
169
|
+
},
|
|
170
|
+
])('registers listeners before forking and aborts without reforking on $testName', (scenario) => {
|
|
171
|
+
const harness = setupMasterRunHarness();
|
|
172
|
+
|
|
173
|
+
expect(harness.operations).toEqual(['on:message', 'fork', 'fork', 'on:exit']);
|
|
174
|
+
expect(harness.mockFork).toHaveBeenCalledTimes(2);
|
|
175
|
+
expect(harness.setIntervalSpy).toHaveBeenCalledTimes(1);
|
|
176
|
+
|
|
177
|
+
harness.clusterHandlers.message(scenario.failureWorker, scenario.failure);
|
|
178
|
+
|
|
179
|
+
expect(() => harness.clusterHandlers.exit(scenario.exitingWorker)).toThrow('process.exit:1');
|
|
180
|
+
expect(harness.mockErrorReporterMessage).toHaveBeenCalledWith(scenario.expectedMessage);
|
|
181
|
+
expect(harness.mockCluster.disconnect).toHaveBeenCalledTimes(1);
|
|
182
|
+
expect(harness.processExitSpy).toHaveBeenCalledWith(1);
|
|
183
|
+
expect(harness.mockFork).toHaveBeenCalledTimes(2);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('keeps the first startup-failure details when multiple workers report failures', () => {
|
|
187
|
+
const harness = setupMasterRunHarness();
|
|
188
|
+
const firstFailure = buildStartupFailureMessage({
|
|
189
|
+
code: 'EACCES',
|
|
190
|
+
message: 'listen EACCES: permission denied 0.0.0.0:80',
|
|
191
|
+
});
|
|
192
|
+
const secondFailure = buildStartupFailureMessage({
|
|
193
|
+
code: 'ECONNREFUSED',
|
|
194
|
+
message: 'listen ECONNREFUSED: connection refused 127.0.0.1:3800',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
harness.clusterHandlers.message({ id: 1, process: { exitCode: 1 } }, firstFailure);
|
|
198
|
+
harness.clusterHandlers.message({ id: 2, process: { exitCode: 1 } }, secondFailure);
|
|
199
|
+
|
|
200
|
+
expect(() => harness.clusterHandlers.exit({ id: 2, process: { exitCode: 1 } })).toThrow('process.exit:1');
|
|
201
|
+
expect(harness.mockErrorReporterMessage).toHaveBeenCalledWith(
|
|
202
|
+
'Node renderer startup failed in worker 1: listen EACCES: permission denied 0.0.0.0:80',
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('reports error only once when multiple workers exit during abort', () => {
|
|
207
|
+
const harness = setupMasterRunHarness();
|
|
208
|
+
// Use a disconnect mock that does NOT invoke the callback, so process.exit
|
|
209
|
+
// is not called and subsequent exit events can be observed.
|
|
210
|
+
harness.mockCluster.disconnect.mockImplementation(() => {});
|
|
211
|
+
|
|
212
|
+
harness.clusterHandlers.message({ id: 1, process: { exitCode: 1 } }, buildStartupFailureMessage());
|
|
213
|
+
|
|
214
|
+
// First exit triggers the error report and disconnect.
|
|
215
|
+
harness.clusterHandlers.exit({ id: 1, process: { exitCode: 1 } });
|
|
216
|
+
expect(harness.mockErrorReporterMessage).toHaveBeenCalledTimes(1);
|
|
217
|
+
expect(harness.mockCluster.disconnect).toHaveBeenCalledTimes(1);
|
|
218
|
+
|
|
219
|
+
// Second worker exit during abort — no duplicate report, no refork.
|
|
220
|
+
harness.clusterHandlers.exit({ id: 2, process: { exitCode: 1 } });
|
|
221
|
+
expect(harness.mockErrorReporterMessage).toHaveBeenCalledTimes(1);
|
|
222
|
+
expect(harness.mockCluster.disconnect).toHaveBeenCalledTimes(1);
|
|
223
|
+
expect(harness.mockFork).toHaveBeenCalledTimes(2);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('does not refork a scheduled-restart worker when aborting for startup failure', () => {
|
|
227
|
+
const harness = setupMasterRunHarness();
|
|
228
|
+
|
|
229
|
+
harness.clusterHandlers.message({ id: 1, process: { exitCode: 1 } }, buildStartupFailureMessage());
|
|
230
|
+
|
|
231
|
+
// A scheduled-restart worker exiting during abort should NOT be reforked.
|
|
232
|
+
expect(() =>
|
|
233
|
+
harness.clusterHandlers.exit({ id: 2, isScheduledRestart: true, process: { exitCode: 0 } }),
|
|
234
|
+
).toThrow('process.exit:1');
|
|
235
|
+
expect(harness.mockFork).toHaveBeenCalledTimes(2);
|
|
236
|
+
expect(harness.mockErrorReporterMessage).toHaveBeenCalledTimes(1);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('restarts scheduled-restart workers without reporting an error', () => {
|
|
240
|
+
const harness = setupMasterRunHarness();
|
|
241
|
+
const worker: MockWorker = { id: 1, isScheduledRestart: true, process: { exitCode: 0 } };
|
|
242
|
+
|
|
243
|
+
harness.clusterHandlers.exit(worker);
|
|
244
|
+
|
|
245
|
+
expect(harness.mockFork).toHaveBeenCalledTimes(3);
|
|
246
|
+
expect(harness.mockErrorReporterMessage).not.toHaveBeenCalled();
|
|
247
|
+
expect(harness.processExitSpy).not.toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('waits one tick for startup-failure IPC before classifying an unexpected crash', async () => {
|
|
251
|
+
const harness = setupMasterRunHarness();
|
|
252
|
+
harness.mockCluster.disconnect.mockImplementation(() => {});
|
|
253
|
+
const worker = { id: 1, process: { exitCode: 1 } };
|
|
254
|
+
|
|
255
|
+
// Exit arrives first.
|
|
256
|
+
harness.clusterHandlers.exit(worker);
|
|
257
|
+
// Startup-failure message arrives before the deferred crash classification runs.
|
|
258
|
+
harness.clusterHandlers.message(worker, buildStartupFailureMessage());
|
|
259
|
+
await waitForSetImmediate();
|
|
260
|
+
|
|
261
|
+
expect(harness.mockErrorReporterMessage).toHaveBeenCalledWith(
|
|
262
|
+
'Node renderer startup failed: localhost:3800 is already in use',
|
|
263
|
+
);
|
|
264
|
+
expect(harness.mockCluster.disconnect).toHaveBeenCalledTimes(1);
|
|
265
|
+
expect(harness.mockFork).toHaveBeenCalledTimes(2);
|
|
266
|
+
expect(harness.processExitSpy).not.toHaveBeenCalled();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('reforks on unexpected runtime crash when no startup failure was received', async () => {
|
|
270
|
+
const harness = setupMasterRunHarness();
|
|
271
|
+
|
|
272
|
+
harness.clusterHandlers.exit({ id: 3, process: { exitCode: 1 } });
|
|
273
|
+
await waitForSetImmediate();
|
|
274
|
+
|
|
275
|
+
expect(harness.mockErrorReporterMessage).toHaveBeenCalledWith(
|
|
276
|
+
'Worker 3 died UNEXPECTEDLY :(, restarting',
|
|
277
|
+
);
|
|
278
|
+
expect(harness.mockFork).toHaveBeenCalledTimes(3);
|
|
279
|
+
expect(harness.processExitSpy).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('ignores malformed startup-failure messages and treats exit as runtime crash', async () => {
|
|
283
|
+
const harness = setupMasterRunHarness();
|
|
284
|
+
|
|
285
|
+
harness.clusterHandlers.message({ id: 1, process: { exitCode: 1 } }, { type: WORKER_STARTUP_FAILURE });
|
|
286
|
+
harness.clusterHandlers.exit({ id: 1, process: { exitCode: 1 } });
|
|
287
|
+
await waitForSetImmediate();
|
|
288
|
+
|
|
289
|
+
expect(harness.mockErrorReporterMessage).toHaveBeenCalledWith(
|
|
290
|
+
'Worker 1 died UNEXPECTEDLY :(, restarting',
|
|
291
|
+
);
|
|
292
|
+
expect(harness.mockFork).toHaveBeenCalledTimes(3);
|
|
293
|
+
expect(harness.processExitSpy).not.toHaveBeenCalled();
|
|
294
|
+
});
|
|
295
|
+
});
|
package/tests/worker.test.ts
CHANGED
|
@@ -101,6 +101,154 @@ describe('worker', () => {
|
|
|
101
101
|
expect(fs.existsSync(assetPathOther(testName, String(SECONDARY_BUNDLE_TIMESTAMP)))).toBe(true);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest returns actionable error when renderingRequest is missing', async () => {
|
|
105
|
+
const app = worker({
|
|
106
|
+
serverBundleCachePath: serverBundleCachePathForTest(),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const res = await app
|
|
110
|
+
.inject()
|
|
111
|
+
.post(`/bundles/${BUNDLE_TIMESTAMP}/render/d41d8cd98f00b204e9800998ecf8427e`)
|
|
112
|
+
.payload({
|
|
113
|
+
gemVersion,
|
|
114
|
+
protocolVersion,
|
|
115
|
+
railsEnv,
|
|
116
|
+
})
|
|
117
|
+
.end();
|
|
118
|
+
|
|
119
|
+
expect(res.statusCode).toBe(400);
|
|
120
|
+
expect(res.payload).toContain('Invalid "renderingRequest" field in render request.');
|
|
121
|
+
expect(res.payload).toContain('Received type: undefined.');
|
|
122
|
+
expect(res.payload).toContain('Likely causes: request body truncation');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest does not notify errorReporter for malformed renderingRequest', async () => {
|
|
126
|
+
const reportMessageSpy = jest.spyOn(errorReporter, 'message').mockImplementation(jest.fn());
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const app = worker({
|
|
130
|
+
serverBundleCachePath: serverBundleCachePathForTest(),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const res = await app
|
|
134
|
+
.inject()
|
|
135
|
+
.post(`/bundles/${BUNDLE_TIMESTAMP}/render/d41d8cd98f00b204e9800998ecf8427e`)
|
|
136
|
+
.payload({
|
|
137
|
+
gemVersion,
|
|
138
|
+
protocolVersion,
|
|
139
|
+
railsEnv,
|
|
140
|
+
})
|
|
141
|
+
.end();
|
|
142
|
+
|
|
143
|
+
expect(res.statusCode).toBe(400);
|
|
144
|
+
expect(reportMessageSpy).not.toHaveBeenCalled();
|
|
145
|
+
} finally {
|
|
146
|
+
reportMessageSpy.mockRestore();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest returns actionable error when renderingRequest is null', async () => {
|
|
151
|
+
const app = worker({
|
|
152
|
+
serverBundleCachePath: serverBundleCachePathForTest(),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const res = await app
|
|
156
|
+
.inject()
|
|
157
|
+
.post(`/bundles/${BUNDLE_TIMESTAMP}/render/d41d8cd98f00b204e9800998ecf8427e`)
|
|
158
|
+
.payload({
|
|
159
|
+
gemVersion,
|
|
160
|
+
protocolVersion,
|
|
161
|
+
railsEnv,
|
|
162
|
+
renderingRequest: null,
|
|
163
|
+
})
|
|
164
|
+
.end();
|
|
165
|
+
|
|
166
|
+
expect(res.statusCode).toBe(400);
|
|
167
|
+
expect(res.payload).toContain('Invalid "renderingRequest" field in render request.');
|
|
168
|
+
expect(res.payload).toContain('Received type: null.');
|
|
169
|
+
expect(res.payload).toContain('Likely causes: request body truncation');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest returns actionable error when renderingRequest is empty string', async () => {
|
|
173
|
+
const app = worker({
|
|
174
|
+
serverBundleCachePath: serverBundleCachePathForTest(),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const res = await app
|
|
178
|
+
.inject()
|
|
179
|
+
.post(`/bundles/${BUNDLE_TIMESTAMP}/render/d41d8cd98f00b204e9800998ecf8427e`)
|
|
180
|
+
.payload({
|
|
181
|
+
gemVersion,
|
|
182
|
+
protocolVersion,
|
|
183
|
+
railsEnv,
|
|
184
|
+
renderingRequest: '',
|
|
185
|
+
})
|
|
186
|
+
.end();
|
|
187
|
+
|
|
188
|
+
expect(res.statusCode).toBe(400);
|
|
189
|
+
expect(res.payload).toContain('Invalid "renderingRequest" field in render request.');
|
|
190
|
+
expect(res.payload).toContain('Received type: empty string.');
|
|
191
|
+
expect(res.payload).toContain('Likely causes: request body truncation');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest returns actionable error when renderingRequest is an array', async () => {
|
|
195
|
+
const app = worker({
|
|
196
|
+
serverBundleCachePath: serverBundleCachePathForTest(),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const res = await app
|
|
200
|
+
.inject()
|
|
201
|
+
.post(`/bundles/${BUNDLE_TIMESTAMP}/render/d41d8cd98f00b204e9800998ecf8427e`)
|
|
202
|
+
.payload({
|
|
203
|
+
gemVersion,
|
|
204
|
+
protocolVersion,
|
|
205
|
+
railsEnv,
|
|
206
|
+
renderingRequest: ['a', 'b'],
|
|
207
|
+
})
|
|
208
|
+
.end();
|
|
209
|
+
|
|
210
|
+
expect(res.statusCode).toBe(400);
|
|
211
|
+
expect(res.payload).toContain('Invalid "renderingRequest" field in render request.');
|
|
212
|
+
expect(res.payload).toContain('Received type: array.');
|
|
213
|
+
expect(res.payload).not.toMatch(/Received body keys:.*renderingRequest/);
|
|
214
|
+
expect(res.payload).toContain('Likely causes: request body truncation');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest filters sensitive body keys case-insensitively in invalid renderingRequest diagnostics', async () => {
|
|
218
|
+
const app = worker({
|
|
219
|
+
serverBundleCachePath: serverBundleCachePathForTest(),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const res = await app
|
|
223
|
+
.inject()
|
|
224
|
+
.post(`/bundles/${BUNDLE_TIMESTAMP}/render/d41d8cd98f00b204e9800998ecf8427e`)
|
|
225
|
+
.payload({
|
|
226
|
+
gemVersion,
|
|
227
|
+
protocolVersion,
|
|
228
|
+
railsEnv,
|
|
229
|
+
Password: 'super-secret',
|
|
230
|
+
apiKey: 'token',
|
|
231
|
+
Authorization: 'Bearer abc',
|
|
232
|
+
AUTH_TOKEN: 'auth',
|
|
233
|
+
accessToken: 'access',
|
|
234
|
+
authToken: 'auth-camel',
|
|
235
|
+
Credentials: 'creds-secret',
|
|
236
|
+
safeField: 'safe',
|
|
237
|
+
})
|
|
238
|
+
.end();
|
|
239
|
+
|
|
240
|
+
expect(res.statusCode).toBe(400);
|
|
241
|
+
expect(res.payload).toContain('Received body keys:');
|
|
242
|
+
expect(res.payload).not.toContain('Password');
|
|
243
|
+
expect(res.payload).not.toContain('apiKey');
|
|
244
|
+
expect(res.payload).not.toContain('Authorization');
|
|
245
|
+
expect(res.payload).not.toContain('AUTH_TOKEN');
|
|
246
|
+
expect(res.payload).not.toContain('accessToken');
|
|
247
|
+
expect(res.payload).not.toContain('authToken');
|
|
248
|
+
expect(res.payload).not.toContain('Credentials');
|
|
249
|
+
expect(res.payload).toContain('safeField');
|
|
250
|
+
});
|
|
251
|
+
|
|
104
252
|
test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest reports unexpected handleRenderRequest failures once', async () => {
|
|
105
253
|
const buildVMSpy = jest.spyOn(vm, 'buildVM').mockRejectedValueOnce(new Error('Injected buildVM failure'));
|
|
106
254
|
const reportMessageSpy = jest.spyOn(errorReporter, 'message').mockImplementation(jest.fn());
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WORKER_STARTUP_FAILURE,
|
|
3
|
+
isWorkerStartupFailureMessage,
|
|
4
|
+
type WorkerStartupFailureMessage,
|
|
5
|
+
} from '../src/shared/workerMessages';
|
|
6
|
+
import { handleStartupListenError } from '../src/worker/startupErrorHandler';
|
|
7
|
+
|
|
8
|
+
describe('isWorkerStartupFailureMessage', () => {
|
|
9
|
+
it('returns true for a valid startup failure message', () => {
|
|
10
|
+
const msg: WorkerStartupFailureMessage = {
|
|
11
|
+
type: WORKER_STARTUP_FAILURE,
|
|
12
|
+
stage: 'listen',
|
|
13
|
+
code: 'EADDRINUSE',
|
|
14
|
+
errno: -48,
|
|
15
|
+
syscall: 'listen',
|
|
16
|
+
host: 'localhost',
|
|
17
|
+
port: 3800,
|
|
18
|
+
message: 'listen EADDRINUSE: address already in use :::3800',
|
|
19
|
+
};
|
|
20
|
+
expect(isWorkerStartupFailureMessage(msg)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns false for null', () => {
|
|
24
|
+
expect(isWorkerStartupFailureMessage(null)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns false for a string', () => {
|
|
28
|
+
expect(isWorkerStartupFailureMessage('hello')).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns false for an object with a different type', () => {
|
|
32
|
+
expect(isWorkerStartupFailureMessage({ type: 'OTHER' })).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns false for an object without type', () => {
|
|
36
|
+
expect(isWorkerStartupFailureMessage({ stage: 'listen' })).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns false for a non-integer port', () => {
|
|
40
|
+
expect(
|
|
41
|
+
isWorkerStartupFailureMessage({
|
|
42
|
+
type: WORKER_STARTUP_FAILURE,
|
|
43
|
+
stage: 'listen',
|
|
44
|
+
host: 'localhost',
|
|
45
|
+
port: 3800.5,
|
|
46
|
+
message: 'some error',
|
|
47
|
+
}),
|
|
48
|
+
).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns false for an out-of-range port', () => {
|
|
52
|
+
expect(
|
|
53
|
+
isWorkerStartupFailureMessage({
|
|
54
|
+
type: WORKER_STARTUP_FAILURE,
|
|
55
|
+
stage: 'listen',
|
|
56
|
+
host: 'localhost',
|
|
57
|
+
port: 70000,
|
|
58
|
+
message: 'some error',
|
|
59
|
+
}),
|
|
60
|
+
).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns false for a negative port', () => {
|
|
64
|
+
expect(
|
|
65
|
+
isWorkerStartupFailureMessage({
|
|
66
|
+
type: WORKER_STARTUP_FAILURE,
|
|
67
|
+
stage: 'listen',
|
|
68
|
+
host: 'localhost',
|
|
69
|
+
port: -1,
|
|
70
|
+
message: 'some error',
|
|
71
|
+
}),
|
|
72
|
+
).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('worker startup listen error handling', () => {
|
|
77
|
+
const buildListenError = () =>
|
|
78
|
+
Object.assign(new Error('listen EADDRINUSE: address already in use :::3800'), {
|
|
79
|
+
code: 'EADDRINUSE',
|
|
80
|
+
errno: -48,
|
|
81
|
+
syscall: 'listen',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
jest.restoreAllMocks();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('sends WORKER_STARTUP_FAILURE message in clustered mode via the production handler', () => {
|
|
89
|
+
const sentMessages: unknown[] = [];
|
|
90
|
+
const exitCalls: number[] = [];
|
|
91
|
+
const send = ((msg: unknown, _handle?: unknown, _options?: unknown, callback?: () => void) => {
|
|
92
|
+
sentMessages.push(msg);
|
|
93
|
+
callback?.();
|
|
94
|
+
return true;
|
|
95
|
+
}) as NodeJS.Process['send'];
|
|
96
|
+
const exit = ((code?: number) => {
|
|
97
|
+
exitCalls.push(code ?? 0);
|
|
98
|
+
}) as NodeJS.Process['exit'];
|
|
99
|
+
|
|
100
|
+
handleStartupListenError({
|
|
101
|
+
err: buildListenError(),
|
|
102
|
+
host: 'localhost',
|
|
103
|
+
port: 3800,
|
|
104
|
+
isWorker: true,
|
|
105
|
+
send,
|
|
106
|
+
exit,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(sentMessages).toHaveLength(1);
|
|
110
|
+
expect(isWorkerStartupFailureMessage(sentMessages[0])).toBe(true);
|
|
111
|
+
expect(sentMessages[0]).toMatchObject({
|
|
112
|
+
type: WORKER_STARTUP_FAILURE,
|
|
113
|
+
stage: 'listen',
|
|
114
|
+
host: 'localhost',
|
|
115
|
+
port: 3800,
|
|
116
|
+
code: 'EADDRINUSE',
|
|
117
|
+
});
|
|
118
|
+
expect(exitCalls).toEqual([1]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('exits without IPC in single-process mode via the production handler', () => {
|
|
122
|
+
const send = jest.fn();
|
|
123
|
+
const exitCalls: number[] = [];
|
|
124
|
+
const exit = ((code?: number) => {
|
|
125
|
+
exitCalls.push(code ?? 0);
|
|
126
|
+
}) as NodeJS.Process['exit'];
|
|
127
|
+
|
|
128
|
+
handleStartupListenError({
|
|
129
|
+
err: buildListenError(),
|
|
130
|
+
host: 'localhost',
|
|
131
|
+
port: 3800,
|
|
132
|
+
isWorker: false,
|
|
133
|
+
send: send as unknown as NodeJS.Process['send'],
|
|
134
|
+
exit,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(send).not.toHaveBeenCalled();
|
|
138
|
+
expect(exitCalls).toEqual([1]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('logs a warning and exits when cluster worker has no IPC channel', () => {
|
|
142
|
+
// Temporarily remove process.send so the fallback `send ?? process.send?.bind(process)`
|
|
143
|
+
// resolves to undefined, simulating a worker whose IPC channel has been destroyed.
|
|
144
|
+
const originalSend = process.send;
|
|
145
|
+
delete (process as { send?: typeof process.send }).send;
|
|
146
|
+
|
|
147
|
+
const exitCalls: number[] = [];
|
|
148
|
+
const exit = ((code?: number) => {
|
|
149
|
+
exitCalls.push(code ?? 0);
|
|
150
|
+
}) as NodeJS.Process['exit'];
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
handleStartupListenError({
|
|
154
|
+
err: buildListenError(),
|
|
155
|
+
host: 'localhost',
|
|
156
|
+
port: 3800,
|
|
157
|
+
isWorker: true,
|
|
158
|
+
send: undefined,
|
|
159
|
+
exit,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(exitCalls).toEqual([1]);
|
|
163
|
+
} finally {
|
|
164
|
+
process.send = originalSend;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('exits when process.send throws synchronously', () => {
|
|
169
|
+
const send = (() => {
|
|
170
|
+
throw new Error('ERR_IPC_CHANNEL_CLOSED');
|
|
171
|
+
}) as NodeJS.Process['send'];
|
|
172
|
+
const exitCalls: number[] = [];
|
|
173
|
+
const exit = ((code?: number) => {
|
|
174
|
+
exitCalls.push(code ?? 0);
|
|
175
|
+
}) as NodeJS.Process['exit'];
|
|
176
|
+
|
|
177
|
+
handleStartupListenError({
|
|
178
|
+
err: buildListenError(),
|
|
179
|
+
host: 'localhost',
|
|
180
|
+
port: 3800,
|
|
181
|
+
isWorker: true,
|
|
182
|
+
send,
|
|
183
|
+
exit,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(exitCalls).toEqual([1]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('exits via timeout fallback when IPC callback is never invoked', () => {
|
|
190
|
+
jest.useFakeTimers();
|
|
191
|
+
const exitCalls: number[] = [];
|
|
192
|
+
// send accepts the message but never invokes the callback, simulating a
|
|
193
|
+
// half-broken IPC channel.
|
|
194
|
+
const send = ((_msg: unknown, _handle?: unknown, _options?: unknown, _callback?: () => void) =>
|
|
195
|
+
true) as NodeJS.Process['send'];
|
|
196
|
+
const exit = ((code?: number) => {
|
|
197
|
+
exitCalls.push(code ?? 0);
|
|
198
|
+
}) as NodeJS.Process['exit'];
|
|
199
|
+
|
|
200
|
+
handleStartupListenError({
|
|
201
|
+
err: buildListenError(),
|
|
202
|
+
host: 'localhost',
|
|
203
|
+
port: 3800,
|
|
204
|
+
isWorker: true,
|
|
205
|
+
send,
|
|
206
|
+
exit,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Callback was never invoked, so exit hasn't been called yet.
|
|
210
|
+
expect(exitCalls).toEqual([]);
|
|
211
|
+
|
|
212
|
+
// Advance past the IPC_SEND_TIMEOUT_MS (2000ms) fallback timer.
|
|
213
|
+
jest.advanceTimersByTime(2000);
|
|
214
|
+
expect(exitCalls).toEqual([1]);
|
|
215
|
+
|
|
216
|
+
jest.useRealTimers();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('does not exit twice when callback fires after timeout', () => {
|
|
220
|
+
jest.useFakeTimers();
|
|
221
|
+
const exitCalls: number[] = [];
|
|
222
|
+
let savedCallback: (() => void) | undefined;
|
|
223
|
+
const send = ((_msg: unknown, _handle?: unknown, _options?: unknown, callback?: () => void) => {
|
|
224
|
+
savedCallback = callback;
|
|
225
|
+
return true;
|
|
226
|
+
}) as NodeJS.Process['send'];
|
|
227
|
+
const exit = ((code?: number) => {
|
|
228
|
+
exitCalls.push(code ?? 0);
|
|
229
|
+
}) as NodeJS.Process['exit'];
|
|
230
|
+
|
|
231
|
+
handleStartupListenError({
|
|
232
|
+
err: buildListenError(),
|
|
233
|
+
host: 'localhost',
|
|
234
|
+
port: 3800,
|
|
235
|
+
isWorker: true,
|
|
236
|
+
send,
|
|
237
|
+
exit,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Timeout fires first.
|
|
241
|
+
jest.advanceTimersByTime(2000);
|
|
242
|
+
expect(exitCalls).toEqual([1]);
|
|
243
|
+
|
|
244
|
+
// Late callback — should be a no-op.
|
|
245
|
+
savedCallback?.();
|
|
246
|
+
expect(exitCalls).toEqual([1]);
|
|
247
|
+
|
|
248
|
+
jest.useRealTimers();
|
|
249
|
+
});
|
|
250
|
+
});
|