Skip to content

Commit ad21317

Browse files
committed
Fixes nodejs#50880 - enable passthrough IPC in watch mode
1 parent c5f4629 commit ad21317

File tree

3 files changed

+133
-30
lines changed

3 files changed

+133
-30
lines changed

lib/internal/main/watch_mode.js

+10-7
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ function start() {
5959
process.stdout.write(`${red}Failed running ${kCommandStr}${white}\n`);
6060
}
6161
});
62+
return child;
6263
}
6364

6465
async function killAndWait(signal = kKillSignal, force = false) {
@@ -91,29 +92,31 @@ function reportGracefulTermination() {
9192
};
9293
}
9394

94-
async function stop() {
95+
async function stop(child) {
96+
// without this line, the child process is still able to receive IPC, but is unable to send additional messages
97+
watcher.destroyIPC(child);
9598
watcher.clearFileFilters();
9699
const clearGraceReport = reportGracefulTermination();
97100
await killAndWait();
98101
clearGraceReport();
99102
}
100103

101-
async function restart() {
104+
async function restart(child) {
102105
if (!kPreserveOutput) process.stdout.write(clear);
103106
process.stdout.write(`${green}Restarting ${kCommandStr}${white}\n`);
104-
await stop();
105-
start();
107+
await stop(child);
108+
return start();
106109
}
107110

108111
(async () => {
109112
emitExperimentalWarning('Watch mode');
110-
113+
let child;
111114
try {
112-
start();
115+
child = start();
113116

114117
// eslint-disable-next-line no-unused-vars
115118
for await (const _ of on(watcher, 'changed')) {
116-
await restart();
119+
child = await restart(child);
117120
}
118121
} catch (error) {
119122
triggerUncaughtException(error, true /* fromPromise */);

lib/internal/watch_mode/files_watcher.js

+24
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,16 @@ class FilesWatcher extends EventEmitter {
3131
#throttle;
3232
#mode;
3333

34+
#wantsPassthroughIPC = false;
35+
3436
constructor({ throttle = 500, mode = 'filter' } = kEmptyObject) {
3537
super();
3638

3739
validateNumber(throttle, 'options.throttle', 0, TIMEOUT_MAX);
3840
validateOneOf(mode, 'options.mode', ['filter', 'all']);
3941
this.#throttle = throttle;
4042
this.#mode = mode;
43+
this.#wantsPassthroughIPC = !!process.send;
4144
}
4245

4346
#isPathWatched(path) {
@@ -117,7 +120,28 @@ class FilesWatcher extends EventEmitter {
117120
this.#ownerDependencies.set(owner, dependencies);
118121
}
119122
}
123+
124+
125+
#setupIPC(child) {
126+
child._ipcMessages = {
127+
parentToChild: message => child.send(message),
128+
childToParent: message => process.send(message)
129+
};
130+
process.on("message", child._ipcMessages.parentToChild);
131+
child.on("message", child._ipcMessages.childToParent);
132+
}
133+
134+
destroyIPC(child) {
135+
if (this.#wantsPassthroughIPC) {
136+
process.off("message", child._ipcMessages.parentToChild);
137+
child.off("message", child._ipcMessages.childToParent);
138+
}
139+
}
140+
120141
watchChildProcessModules(child, key = null) {
142+
if (this.#wantsPassthroughIPC) {
143+
this.#setupIPC(child);
144+
}
121145
if (this.#mode !== 'filter') {
122146
return;
123147
}

test/sequential/test-watch-mode.mjs

+99-23
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from 'fs/promises';
12
import * as common from '../common/index.mjs';
23
import * as fixtures from '../common/fixtures.mjs';
34
import tmpdir from '../common/tmpdir.js';
@@ -34,6 +35,8 @@ async function spawnWithRestarts({
3435
watchedFile = file,
3536
restarts = 1,
3637
isReady,
38+
spawnOptions,
39+
returnChild = false
3740
}) {
3841
args ??= [file];
3942
const printedArgs = inspect(args.slice(args.indexOf(file)).join(' '));
@@ -44,30 +47,36 @@ async function spawnWithRestarts({
4447
let cancelRestarts;
4548

4649
disableRestart = true;
47-
const child = spawn(execPath, ['--watch', '--no-warnings', ...args], { encoding: 'utf8' });
48-
child.stderr.on('data', (data) => {
49-
stderr += data;
50-
});
51-
child.stdout.on('data', async (data) => {
52-
if (data.toString().includes('Restarting')) {
53-
disableRestart = true;
54-
}
55-
stdout += data;
56-
const restartsCount = stdout.match(new RegExp(`Restarting ${printedArgs.replace(/\\/g, '\\\\')}`, 'g'))?.length ?? 0;
57-
if (restarts === 0 || !isReady(data.toString())) {
58-
return;
59-
}
60-
if (restartsCount >= restarts) {
61-
cancelRestarts?.();
62-
child.kill();
63-
return;
64-
}
65-
cancelRestarts ??= restart(watchedFile);
66-
if (isReady(data.toString())) {
67-
disableRestart = false;
68-
}
69-
});
50+
const child = spawn(execPath, ['--watch', '--no-warnings', ...args], { encoding: 'utf8', ...spawnOptions });
7051

52+
if (!returnChild) {
53+
child.stderr.on('data', (data) => {
54+
stderr += data;
55+
});
56+
child.stdout.on('data', async (data) => {
57+
if (data.toString().includes('Restarting')) {
58+
disableRestart = true;
59+
}
60+
stdout += data;
61+
const restartsCount = stdout.match(new RegExp(`Restarting ${printedArgs.replace(/\\/g, '\\\\')}`, 'g'))?.length ?? 0;
62+
if (restarts === 0 || !isReady(data.toString())) {
63+
return;
64+
}
65+
if (restartsCount >= restarts) {
66+
cancelRestarts?.();
67+
child.kill();
68+
return;
69+
}
70+
cancelRestarts ??= restart(watchedFile);
71+
if (isReady(data.toString())) {
72+
disableRestart = false;
73+
}
74+
});
75+
}
76+
else {
77+
// this test is doing it's own thing
78+
return { child };
79+
}
7180
await once(child, 'exit');
7281
cancelRestarts?.();
7382
return { stderr, stdout };
@@ -248,6 +257,7 @@ describe('watch mode', { concurrency: false, timeout: 60_000 }, () => {
248257
});
249258
});
250259

260+
251261
// TODO: Remove skip after https://github.com/nodejs/node/pull/45271 lands
252262
it('should not watch when running an missing file', {
253263
skip: !supportsRecursive
@@ -307,4 +317,70 @@ describe('watch mode', { concurrency: false, timeout: 60_000 }, () => {
307317
`Completed running ${inspect(file)}`,
308318
]);
309319
});
320+
321+
it('should pass IPC messages from a spawning parent to the child and back', async () => {
322+
const file = createTmpFile('console.log("running");\nprocess.on("message", (message) => {\n if (message === "exit") {\n process.exit(0);\n } else {\n console.log("Received:", message);\n process.send(message);\n }\n})');
323+
const { child } = await spawnWithRestarts({
324+
file,
325+
args: [file],
326+
spawnOptions: {
327+
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
328+
},
329+
returnChild: true,
330+
restarts: 2
331+
});
332+
333+
let stderr = '';
334+
let stdout = '';
335+
336+
child.stdout.on("data", data => stdout += data);
337+
child.stderr.on("data", data => stderr += data);
338+
async function waitForEcho(msg) {
339+
const receivedPromise = new Promise((resolve) => {
340+
const fn = (message) => {
341+
if (message === msg) {
342+
child.off("message", fn);
343+
resolve();
344+
}
345+
};
346+
child.on("message", fn);
347+
});
348+
child.send(msg);
349+
await receivedPromise;
350+
}
351+
async function waitForText(text) {
352+
const seenPromise = new Promise((resolve) => {
353+
const fn = (data) => {
354+
if (data.toString().includes(text)) {
355+
resolve();
356+
child.stdout.off("data", fn);
357+
}
358+
}
359+
child.stdout.on("data", fn);
360+
});
361+
await seenPromise;
362+
}
363+
364+
await waitForEcho("first message");
365+
const stopRestarts = restart(file);
366+
await waitForText("running");
367+
stopRestarts();
368+
await waitForEcho("second message");
369+
const exitedPromise = once(child, 'exit');
370+
child.send("exit");
371+
await waitForText("Completed");
372+
child.disconnect();
373+
child.kill();
374+
await exitedPromise;
375+
assert.strictEqual(stderr, '');
376+
const lines = stdout.split(/\r?\n/).filter(Boolean);
377+
assert.deepStrictEqual(lines, [
378+
'running',
379+
'Received: first message',
380+
`Restarting '${file}'`,
381+
'running',
382+
'Received: second message',
383+
`Completed running ${inspect(file)}`,
384+
]);
385+
});
310386
});

0 commit comments

Comments
 (0)