Skip to content

Commit e324efa

Browse files
committed
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
1 parent 75e4bbe commit e324efa

2 files changed

Lines changed: 81 additions & 9 deletions

File tree

‎lib/internal/main/watch_mode.js‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ const {
77
ArrayPrototypePushApply,
88
ArrayPrototypeSlice,
99
StringPrototypeIncludes,
10+
StringPrototypeIndexOf,
11+
StringPrototypeReplaceAll,
12+
StringPrototypeSlice,
1013
StringPrototypeStartsWith,
1114
} = primordials;
1215

@@ -41,9 +44,23 @@ const kPreserveOutput = getOptionValue('--watch-preserve-output');
4144
const kCommand = ArrayPrototypeSlice(process.argv, 1);
4245
const kCommandStr = inspect(ArrayPrototypeJoin(kCommand, ' '));
4346

47+
// The C++ option parser treats `_` and `-` as equivalent in option names
48+
// (e.g. --watch_path is --watch-path), so normalize before matching.
49+
function normalizeOptionName(arg) {
50+
if (!StringPrototypeStartsWith(arg, '--')) {
51+
return arg;
52+
}
53+
const equalsIndex = StringPrototypeIndexOf(arg, '=');
54+
if (equalsIndex === -1) {
55+
return StringPrototypeReplaceAll(arg, '_', '-');
56+
}
57+
return StringPrototypeReplaceAll(StringPrototypeSlice(arg, 0, equalsIndex), '_', '-') +
58+
StringPrototypeSlice(arg, equalsIndex);
59+
}
60+
4461
const argsWithoutWatchOptions = [];
4562
for (let i = 0; i < process.execArgv.length; i++) {
46-
const arg = process.execArgv[i];
63+
const arg = normalizeOptionName(process.execArgv[i]);
4764
if (StringPrototypeStartsWith(arg, '--watch=')) {
4865
continue;
4966
}
@@ -74,7 +91,7 @@ for (let i = 0; i < process.execArgv.length; i++) {
7491
if (arg === '--experimental-default-config-file') {
7592
continue;
7693
}
77-
ArrayPrototypePush(argsWithoutWatchOptions, arg);
94+
ArrayPrototypePush(argsWithoutWatchOptions, process.execArgv[i]);
7895
}
7996

8097
ArrayPrototypePushApply(argsWithoutWatchOptions, kCommand);
@@ -88,15 +105,16 @@ if (kNodeOptions != null) {
88105
const parts = parseNodeOptionsEnvVar(kNodeOptions);
89106
for (let i = 0; i < parts.length; i++) {
90107
const part = parts[i];
91-
if (part === '--watch' ||
92-
part === '--watch-preserve-output' ||
93-
StringPrototypeStartsWith(part, '--watch=') ||
94-
StringPrototypeStartsWith(part, '--watch-preserve-output=') ||
95-
StringPrototypeStartsWith(part, '--watch-path=') ||
96-
StringPrototypeStartsWith(part, '--watch-kill-signal=')) {
108+
const name = normalizeOptionName(part);
109+
if (name === '--watch' ||
110+
name === '--watch-preserve-output' ||
111+
StringPrototypeStartsWith(name, '--watch=') ||
112+
StringPrototypeStartsWith(name, '--watch-preserve-output=') ||
113+
StringPrototypeStartsWith(name, '--watch-path=') ||
114+
StringPrototypeStartsWith(name, '--watch-kill-signal=')) {
97115
continue;
98116
}
99-
if (part === '--watch-path' || part === '--watch-kill-signal') {
117+
if (name === '--watch-path' || name === '--watch-kill-signal') {
100118
// Skip the flag and its separate value argument
101119
i++;
102120
continue;

‎test/sequential/test-watch-mode.mjs‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,6 +1033,60 @@ process.on('message', (message) => {
10331033
}
10341034
});
10351035

1036+
it('should strip underscore aliases of watch flags from NODE_OPTIONS', async () => {
1037+
// Avoid recursively watching the repository's source and test trees.
1038+
const cwd = tmpdir.resolve('node-options-underscore');
1039+
mkdirSync(path.join(cwd, 'src'), { recursive: true });
1040+
mkdirSync(path.join(cwd, 'test'));
1041+
const file = createTmpFile('console.log(process.env.NODE_OPTIONS);');
1042+
const nodeOptions = [
1043+
'--watch_path=./src',
1044+
'--watch_path', './test',
1045+
'--watch_preserve_output',
1046+
'--watch_preserve_output=true',
1047+
'--watch_kill_signal=SIGKILL',
1048+
'--watch_kill_signal', 'SIGINT',
1049+
'--max_old_space_size=4096',
1050+
'--no-warnings',
1051+
].join(' ');
1052+
const { done, restart } = runInBackground({
1053+
args: [file],
1054+
options: {
1055+
cwd,
1056+
env: { ...process.env, NODE_OPTIONS: nodeOptions },
1057+
},
1058+
});
1059+
1060+
try {
1061+
const { stdout, stderr } = await restart();
1062+
1063+
assert.strictEqual(stderr, '');
1064+
const nodeOptionsLine = stdout.find((line) => line.includes('--max_old_space_size'));
1065+
assert.ok(nodeOptionsLine);
1066+
assert.strictEqual(nodeOptionsLine, '--max_old_space_size=4096 --no-warnings');
1067+
} finally {
1068+
await done();
1069+
}
1070+
});
1071+
1072+
it('should strip --watch_path from execArgv in child process', async () => {
1073+
const dir = tmpdir.resolve('exec-argv-underscore');
1074+
mkdirSync(dir, { recursive: true });
1075+
const file = createTmpFile('console.log(JSON.stringify(process.execArgv));');
1076+
const { done, restart } = runInBackground({
1077+
args: [`--watch_path=${dir}`, file],
1078+
});
1079+
1080+
try {
1081+
const { stdout, stderr } = await restart();
1082+
1083+
assert.strictEqual(stderr, '');
1084+
assert.ok(stdout.includes('["--no-warnings"]'));
1085+
} finally {
1086+
await done();
1087+
}
1088+
});
1089+
10361090
it('should not strip --watch when it appears inside a quoted NODE_OPTIONS value', {
10371091
// Honoring --require from NODE_OPTIONS is required for this test.
10381092
skip: !!process.config.variables.node_without_node_options,

0 commit comments

Comments
 (0)