react-on-rails-pro-node-renderer 17.0.0-rc.2 → 17.0.0-rc.3

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.
Files changed (2) hide show
  1. package/lib/master.js +169 -29
  2. package/package.json +2 -2
package/lib/master.js CHANGED
@@ -66,6 +66,8 @@ const errorReporter = __importStar(require("./shared/errorReporter.js"));
66
66
  const licenseValidator_js_1 = require("./shared/licenseValidator.js");
67
67
  const runRscPeerCompatibilityCheck_js_1 = require("./shared/runRscPeerCompatibilityCheck.js");
68
68
  const workerMessages_js_1 = require("./shared/workerMessages.js");
69
+ const utils_js_1 = require("./shared/utils.js");
70
+ const shutdownHooks_js_1 = require("./worker/shutdownHooks.js");
69
71
  const MILLISECONDS_IN_MINUTE = 60000;
70
72
  // How often to scan for orphaned upload directories.
71
73
  const ORPHAN_CLEANUP_INTERVAL_MS = 5 * MILLISECONDS_IN_MINUTE;
@@ -73,6 +75,26 @@ const ORPHAN_CLEANUP_INTERVAL_MS = 5 * MILLISECONDS_IN_MINUTE;
73
75
  // Set well above the longest realistic upload duration so that large bundle
74
76
  // uploads in progress are never deleted by the cleanup timer.
75
77
  const ORPHAN_AGE_THRESHOLD_MS = 30 * MILLISECONDS_IN_MINUTE;
78
+ // Give workers a short window to receive SHUTDOWN_WORKER_MESSAGE and enter
79
+ // their own graceful shutdown path before the master falls back to disconnect().
80
+ const MASTER_WORKER_SHUTDOWN_MESSAGE_GRACE_MS = 1000;
81
+ // Hard deadline for the master to exit after it begins draining workers. It
82
+ // starts before workers receive SHUTDOWN_WORKER_MESSAGE, so it must include the
83
+ // message grace window plus the worker hook budget. If a worker is stuck after
84
+ // that point, this guarantees the master still exits.
85
+ const MASTER_SHUTDOWN_TIMEOUT_MS = MASTER_WORKER_SHUTDOWN_MESSAGE_GRACE_MS + shutdownHooks_js_1.WORKER_SHUTDOWN_HOOKS_TIMEOUT_MS;
86
+ // When a worker's event loop is blocked (synchronous render) it can process
87
+ // neither SHUTDOWN_WORKER_MESSAGE nor a disconnect, so only SIGKILL can stop
88
+ // it — and the master must send that SIGKILL while it is still alive itself.
89
+ // Process supervisors kill the master well before MASTER_SHUTDOWN_TIMEOUT_MS
90
+ // (Foreman's default SIGTERM-to-SIGKILL window is 5s), so survivors are
91
+ // force-killed early: after the message grace window has given draining a
92
+ // chance, but safely inside the supervisor's window.
93
+ const SHUTDOWN_WORKER_FORCE_KILL_TIMEOUT_MS = 2000;
94
+ const SIGNAL_EXIT_CODES = {
95
+ SIGINT: 130,
96
+ SIGTERM: 143,
97
+ };
76
98
  function masterRun(runningConfig) {
77
99
  // This is memoized after the wrapper path runs, but still protects direct `./master` entrypoint users.
78
100
  (0, runRscPeerCompatibilityCheck_js_1.runRscPeerCompatibilityCheck)({ proVersion: packageJson_js_1.default.version });
@@ -124,34 +146,130 @@ function masterRun(runningConfig) {
124
146
  })();
125
147
  }, ORPHAN_CLEANUP_INTERVAL_MS);
126
148
  let isAbortingForStartupFailure = false;
149
+ let hasReportedStartupFailure = false;
127
150
  let fatalStartupFailure = null;
128
- let hasInitiatedShutdown = false;
151
+ // Set as soon as any shutdown path (external signal or startup-failure abort)
152
+ // begins. Read by the `cluster.on('exit')` handler to suppress re-forking
153
+ // workers that exit because we are intentionally tearing the cluster down.
154
+ let isShuttingDown = false;
155
+ // Drain every live worker, then exit with `exitCode`. Idempotent: only the
156
+ // first call performs the disconnect + exit; later calls are no-ops so that
157
+ // an external signal and a near-simultaneous startup-failure abort can't both
158
+ // disconnect or schedule duplicate exits.
159
+ //
160
+ // cluster.disconnect() is async, but its callback only proves workers have
161
+ // disconnected from IPC. Workers may still be draining in-flight requests, so
162
+ // external signal shutdown also waits for their exit events before the master
163
+ // exits. A hard-deadline timer guarantees the master still exits if a worker
164
+ // is stuck.
165
+ const currentWorkers = () => Object.values(cluster_1.default.workers ?? {}).filter((worker) => Boolean(worker));
166
+ const forceKillSurvivingWorkers = (workers) => {
167
+ workers.forEach((worker) => {
168
+ const workerProcess = worker.process;
169
+ // isDead() only: ChildProcess.killed means a signal was sent, not that
170
+ // the process died — e.g. a blocked worker surviving destroy()'s SIGTERM.
171
+ if (worker.isDead())
172
+ return;
173
+ try {
174
+ workerProcess.kill('SIGKILL');
175
+ }
176
+ catch (err) {
177
+ log_js_1.default.warn({ msg: 'Error sending SIGKILL to worker during node renderer master shutdown', err });
178
+ }
179
+ });
180
+ };
181
+ const sendGracefulShutdownMessageToWorkers = (workers) => {
182
+ workers.forEach((worker) => {
183
+ try {
184
+ worker.send(utils_js_1.SHUTDOWN_WORKER_MESSAGE);
185
+ }
186
+ catch (err) {
187
+ log_js_1.default.warn({ msg: 'Error sending graceful shutdown message to worker', err });
188
+ }
189
+ });
190
+ };
191
+ const waitForWorkerExits = (workers, onAllWorkersExited) => {
192
+ let remainingWorkers = 0;
193
+ let hasFinished = false;
194
+ const finishIfComplete = () => {
195
+ if (hasFinished || remainingWorkers > 0)
196
+ return;
197
+ hasFinished = true;
198
+ onAllWorkersExited();
199
+ };
200
+ workers.forEach((worker) => {
201
+ if (worker.isDead())
202
+ return;
203
+ remainingWorkers += 1;
204
+ const markWorkerExited = () => {
205
+ remainingWorkers -= 1;
206
+ finishIfComplete();
207
+ };
208
+ worker.once('exit', markWorkerExited);
209
+ if (worker.isDead()) {
210
+ worker.off('exit', markWorkerExited);
211
+ markWorkerExited();
212
+ }
213
+ });
214
+ finishIfComplete();
215
+ };
216
+ const shutdownGracefully = (exitCode, notifyWorkersBeforeDisconnect = false) => {
217
+ if (isShuttingDown)
218
+ return;
219
+ isShuttingDown = true;
220
+ const workersAtShutdown = currentWorkers();
221
+ const shutdownTimer = setTimeout(() => {
222
+ forceKillSurvivingWorkers(workersAtShutdown);
223
+ process.exit(exitCode);
224
+ }, MASTER_SHUTDOWN_TIMEOUT_MS);
225
+ if (typeof shutdownTimer.unref === 'function')
226
+ shutdownTimer.unref();
227
+ // Early force-kill of workers that cannot drain (blocked event loop).
228
+ // Their SIGKILL-induced exits complete the disconnect, which lets the
229
+ // normal waitForWorkerExits path below finish the shutdown promptly.
230
+ const forceKillTimer = setTimeout(() => {
231
+ forceKillSurvivingWorkers(workersAtShutdown);
232
+ }, SHUTDOWN_WORKER_FORCE_KILL_TIMEOUT_MS);
233
+ if (typeof forceKillTimer.unref === 'function')
234
+ forceKillTimer.unref();
235
+ const finishShutdown = () => {
236
+ clearTimeout(shutdownTimer);
237
+ clearTimeout(forceKillTimer);
238
+ process.exit(exitCode);
239
+ };
240
+ const disconnectCluster = () => {
241
+ cluster_1.default.disconnect(() => {
242
+ if (notifyWorkersBeforeDisconnect) {
243
+ waitForWorkerExits(workersAtShutdown, finishShutdown);
244
+ return;
245
+ }
246
+ finishShutdown();
247
+ });
248
+ };
249
+ if (notifyWorkersBeforeDisconnect) {
250
+ sendGracefulShutdownMessageToWorkers(workersAtShutdown);
251
+ setTimeout(disconnectCluster, MASTER_WORKER_SHUTDOWN_MESSAGE_GRACE_MS);
252
+ return;
253
+ }
254
+ disconnectCluster();
255
+ };
129
256
  const abortForStartupFailure = () => {
130
257
  if (!(isAbortingForStartupFailure && fatalStartupFailure))
131
258
  return false;
132
- if (!hasInitiatedShutdown) {
133
- hasInitiatedShutdown = true;
134
- // Note: the exiting worker may differ from the one that sent the
135
- // failure message if multiple workers exit in rapid succession.
136
- // We always report the first failure received.
137
- const { failure, workerId: failedWorkerId } = fatalStartupFailure;
138
- const msg = failure.code === 'EADDRINUSE'
139
- ? `Node renderer startup failed: ${failure.host}:${failure.port} is already in use`
140
- : `Node renderer startup failed in worker ${failedWorkerId}: ${failure.message}`;
141
- errorReporter.message(msg);
142
- // Disconnect all live workers so they release their ports before the
143
- // master exits. cluster.disconnect() is async — the callback fires
144
- // once every worker has disconnected. A hard-deadline timer guarantees
145
- // the master still exits if a worker is stuck (leaked handle, blocking
146
- // syscall, etc.), following the same pattern as restartWorkers.ts.
147
- const MASTER_SHUTDOWN_TIMEOUT_MS = 5000;
148
- const shutdownTimer = setTimeout(() => process.exit(1), MASTER_SHUTDOWN_TIMEOUT_MS);
149
- if (typeof shutdownTimer.unref === 'function')
150
- shutdownTimer.unref();
151
- cluster_1.default.disconnect(() => {
152
- clearTimeout(shutdownTimer);
153
- process.exit(1);
154
- });
259
+ if (hasReportedStartupFailure)
260
+ return true;
261
+ // Note: the exiting worker may differ from the one that sent the
262
+ // failure message if multiple workers exit in rapid succession.
263
+ // We always report the first failure received.
264
+ const { failure, workerId: failedWorkerId } = fatalStartupFailure;
265
+ const msg = failure.code === 'EADDRINUSE'
266
+ ? `Node renderer startup failed: ${failure.host}:${failure.port} is already in use`
267
+ : `Node renderer startup failed in worker ${failedWorkerId}: ${failure.message}`;
268
+ errorReporter.message(msg);
269
+ hasReportedStartupFailure = true;
270
+ if (!isShuttingDown) {
271
+ // Exit non-zero so supervisors (Foreman/systemd/k8s) see the failed start.
272
+ shutdownGracefully(1);
155
273
  }
156
274
  return true;
157
275
  };
@@ -163,17 +281,23 @@ function masterRun(runningConfig) {
163
281
  isAbortingForStartupFailure = true;
164
282
  fatalStartupFailure = { workerId: worker.id, failure: message };
165
283
  });
166
- for (let i = 0; i < workersCount; i += 1) {
167
- cluster_1.default.fork();
168
- }
169
284
  // Listen for dying workers:
170
285
  cluster_1.default.on('exit', (worker) => {
171
286
  // Once a startup failure has been detected, abort regardless of whether
172
287
  // this particular exit was from the failing worker, a scheduled restart,
173
- // or an unrelated crash. Don't fork any more workers.
288
+ // or an unrelated crash. Don't fork any more workers. If an external signal
289
+ // already started shutdown, still report the recorded failure once while
290
+ // preserving the signal-style exit code and avoiding duplicate disconnects.
174
291
  if (abortForStartupFailure()) {
175
292
  return;
176
293
  }
294
+ // If we are intentionally tearing the cluster down (external SIGTERM/SIGINT
295
+ // or a startup-failure abort), workers are expected to exit — never re-fork
296
+ // them, or we would resurrect the processes we are trying to drain and leave
297
+ // them orphaned after the master exits.
298
+ if (isShuttingDown) {
299
+ return;
300
+ }
177
301
  if (worker.isScheduledRestart) {
178
302
  log_js_1.default.info('Restarting worker #%d on schedule', worker.id);
179
303
  cluster_1.default.fork();
@@ -182,7 +306,9 @@ function masterRun(runningConfig) {
182
306
  // Give in-flight startup-failure IPC messages one event-loop turn to be
183
307
  // processed before classifying this as an ordinary runtime crash.
184
308
  setImmediate(() => {
185
- if (abortForStartupFailure())
309
+ // A startup failure may have arrived during this event-loop turn; report
310
+ // it before any signal-shutdown bail-out suppresses crash classification.
311
+ if (abortForStartupFailure() || isShuttingDown)
186
312
  return;
187
313
  // TODO: Track last rendering request per worker.id
188
314
  // TODO: Consider blocking a given rendering request if it kills a worker more than X times
@@ -191,6 +317,20 @@ function masterRun(runningConfig) {
191
317
  cluster_1.default.fork();
192
318
  });
193
319
  });
320
+ // Drain workers on external shutdown signals. Process managers (Foreman,
321
+ // systemd, Kubernetes, Docker) send SIGTERM (and SIGINT on Ctrl-C in a
322
+ // foreground terminal) to the master. Without these handlers the master is
323
+ // killed immediately and its forked workers are left orphaned, holding their
324
+ // ports.
325
+ const handleShutdownSignal = (signal) => {
326
+ log_js_1.default.info('Received %s, draining workers before shutting down the node renderer master', signal);
327
+ shutdownGracefully(SIGNAL_EXIT_CODES[signal] ?? 0, true);
328
+ };
329
+ process.on('SIGTERM', () => handleShutdownSignal('SIGTERM'));
330
+ process.on('SIGINT', () => handleShutdownSignal('SIGINT'));
331
+ for (let i = 0; i < workersCount; i += 1) {
332
+ cluster_1.default.fork();
333
+ }
194
334
  // Schedule regular restarts of workers
195
335
  if (allWorkersRestartInterval && delayBetweenIndividualWorkerRestarts) {
196
336
  log_js_1.default.info('Scheduled workers restarts every %d minutes (%d minutes btw each)', allWorkersRestartInterval, delayBetweenIndividualWorkerRestarts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-on-rails-pro-node-renderer",
3
- "version": "17.0.0-rc.2",
3
+ "version": "17.0.0-rc.3",
4
4
  "protocolVersion": "2.0.0",
5
5
  "description": "React on Rails Pro Node Renderer for server-side rendering",
6
6
  "main": "lib/ReactOnRailsProNodeRenderer.js",
@@ -74,7 +74,7 @@
74
74
  "sentry-testkit": "^5.0.6",
75
75
  "touch": "^3.1.0",
76
76
  "typescript": "^5.4.3",
77
- "react-on-rails": "17.0.0-rc.2"
77
+ "react-on-rails": "17.0.0-rc.3"
78
78
  },
79
79
  "peerDependencies": {
80
80
  "@honeybadger-io/js": ">=4.0.0",