Commit 57bb81779be for nodejs
commit 57bb81779be411b906feb7ce6481d42679f0d01b
Author: Trivikram Kamat <trivikr.dev@gmail.com>
Date: Tue Oct 6 08:59:00 2026 -0700
watch: strip underscore aliases of watch flags
The C++ option parser treats `_` and `-` as the same character in
option names, so `--watch_path` is the same as `--watch-path`.
Watch mode compared spellings literally when it removed its own flags
from `process.execArgv` and `NODE_OPTIONS` before spawning the child.
The underscore spellings got through, so each child went back into
watch mode and spawned another child, and the application never ran.
Normalize option names before comparing them. Forward the original
arguments to the child unchanged.
Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com>
Assisted-by: claude:opus-5.5
PR-URL: https://github.com/nodejs/node/pull/66365
Fixes: https://github.com/nodejs/node/issues/66364
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
diff --git a/lib/internal/main/watch_mode.js b/lib/internal/main/watch_mode.js
index 8d6b2b23dd1..1cb73be912e 100644
--- a/lib/internal/main/watch_mode.js
+++ b/lib/internal/main/watch_mode.js
@@ -7,7 +7,9 @@ const {
ArrayPrototypePushApply,
ArrayPrototypeSlice,
StringPrototypeIncludes,
+ StringPrototypeIndexOf,
StringPrototypeReplaceAll,
+ StringPrototypeSlice,
StringPrototypeStartsWith,
} = primordials;
@@ -42,9 +44,23 @@ const kPreserveOutput = getOptionValue('--watch-preserve-output');
const kCommand = ArrayPrototypeSlice(process.argv, 1);
const kCommandStr = inspect(ArrayPrototypeJoin(kCommand, ' '));
+// The C++ option parser treats `_` and `-` as equivalent in option names
+// (e.g. --watch_path is --watch-path), so normalize before matching.
+function normalizeOptionName(arg) {
+ if (!StringPrototypeStartsWith(arg, '--')) {
+ return arg;
+ }
+ const equalsIndex = StringPrototypeIndexOf(arg, '=');
+ if (equalsIndex === -1) {
+ return StringPrototypeReplaceAll(arg, '_', '-');
+ }
+ return StringPrototypeReplaceAll(StringPrototypeSlice(arg, 0, equalsIndex), '_', '-') +
+ StringPrototypeSlice(arg, equalsIndex);
+}
+
const argsWithoutWatchOptions = [];
for (let i = 0; i < process.execArgv.length; i++) {
- const arg = process.execArgv[i];
+ const arg = normalizeOptionName(process.execArgv[i]);
if (StringPrototypeStartsWith(arg, '--watch=')) {
continue;
}
@@ -77,7 +93,7 @@ for (let i = 0; i < process.execArgv.length; i++) {
if (arg === '--experimental-default-config-file') {
continue;
}
- ArrayPrototypePush(argsWithoutWatchOptions, arg);
+ ArrayPrototypePush(argsWithoutWatchOptions, process.execArgv[i]);
}
ArrayPrototypePushApply(argsWithoutWatchOptions, kCommand);
@@ -91,15 +107,16 @@ if (kNodeOptions != null) {
const parts = parseNodeOptionsEnvVar(kNodeOptions);
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
- if (part === '--watch' ||
- part === '--watch-preserve-output' ||
- StringPrototypeStartsWith(part, '--watch=') ||
- StringPrototypeStartsWith(part, '--watch-preserve-output=') ||
- StringPrototypeStartsWith(part, '--watch-path=') ||
- StringPrototypeStartsWith(part, '--watch-kill-signal=')) {
+ const name = normalizeOptionName(part);
+ if (name === '--watch' ||
+ name === '--watch-preserve-output' ||
+ StringPrototypeStartsWith(name, '--watch=') ||
+ StringPrototypeStartsWith(name, '--watch-preserve-output=') ||
+ StringPrototypeStartsWith(name, '--watch-path=') ||
+ StringPrototypeStartsWith(name, '--watch-kill-signal=')) {
continue;
}
- if (part === '--watch-path' || part === '--watch-kill-signal') {
+ if (name === '--watch-path' || name === '--watch-kill-signal') {
// Skip the flag and its separate value argument
i++;
continue;
diff --git a/test/sequential/test-watch-mode.mjs b/test/sequential/test-watch-mode.mjs
index 1fe4f0597cb..6b7d9f02194 100644
--- a/test/sequential/test-watch-mode.mjs
+++ b/test/sequential/test-watch-mode.mjs
@@ -1033,6 +1033,60 @@ process.on('message', (message) => {
}
});
+ it('should strip underscore aliases of watch flags from NODE_OPTIONS', async () => {
+ // Avoid recursively watching the repository's source and test trees.
+ const cwd = tmpdir.resolve('node-options-underscore');
+ mkdirSync(path.join(cwd, 'src'), { recursive: true });
+ mkdirSync(path.join(cwd, 'test'));
+ const file = createTmpFile('console.log(process.env.NODE_OPTIONS);');
+ const nodeOptions = [
+ '--watch_path=./src',
+ '--watch_path', './test',
+ '--watch_preserve_output',
+ '--watch_preserve_output=true',
+ '--watch_kill_signal=SIGKILL',
+ '--watch_kill_signal', 'SIGINT',
+ '--max_old_space_size=4096',
+ '--no-warnings',
+ ].join(' ');
+ const { done, restart } = runInBackground({
+ args: ['--watch', file],
+ options: {
+ cwd,
+ env: { ...process.env, NODE_OPTIONS: nodeOptions },
+ },
+ });
+
+ try {
+ const { stdout, stderr } = await restart();
+
+ assert.strictEqual(stderr, '');
+ const nodeOptionsLine = stdout.find((line) => line.includes('--max_old_space_size'));
+ assert.ok(nodeOptionsLine);
+ assert.strictEqual(nodeOptionsLine, '--max_old_space_size=4096 --no-warnings');
+ } finally {
+ await done();
+ }
+ });
+
+ it('should strip --watch_path from execArgv in child process', async () => {
+ const dir = tmpdir.resolve('exec-argv-underscore');
+ mkdirSync(dir, { recursive: true });
+ const file = createTmpFile('console.log(JSON.stringify(process.execArgv));');
+ const { done, restart } = runInBackground({
+ args: [`--watch_path=${dir}`, file],
+ });
+
+ try {
+ const { stdout, stderr } = await restart();
+
+ assert.strictEqual(stderr, '');
+ assert.ok(stdout.includes('["--no-warnings"]'));
+ } finally {
+ await done();
+ }
+ });
+
it('should not strip --watch when it appears inside a quoted NODE_OPTIONS value', {
// Honoring --require from NODE_OPTIONS is required for this test.
skip: !!process.config.variables.node_without_node_options,