Skip to content

Commit 1eacebb

Browse files
watch: strip watch flags from NODE_OPTIONS in child process
Signed-off-by: marcopiraccini <marco.piraccini@gmail.com> PR-URL: #62143 Fixes: #61740 Reviewed-By: Moshe Atlow <moshe@atlow.co.il> Reviewed-By: Paolo Insogna <paolo@cowtech.it> Reviewed-By: René <contact.9a5d6388@renegade334.me.uk> Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
1 parent dd9f4da commit 1eacebb

5 files changed

Lines changed: 199 additions & 2 deletions

File tree

‎lib/internal/main/watch_mode.js‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const {
66
ArrayPrototypePush,
77
ArrayPrototypePushApply,
88
ArrayPrototypeSlice,
9+
StringPrototypeIncludes,
910
StringPrototypeStartsWith,
1011
} = primordials;
1112

@@ -17,7 +18,7 @@ const {
1718
triggerUncaughtException,
1819
exitCodes: { kNoFailure },
1920
} = internalBinding('errors');
20-
const { getOptionValue } = require('internal/options');
21+
const { getOptionValue, parseNodeOptionsEnvVar } = require('internal/options');
2122
const { FilesWatcher } = require('internal/watch_mode/files_watcher');
2223
const { green, blue, red, white, clear } = require('internal/util/colors');
2324
const { convertToValidSignal, kEmptyObject } = require('internal/util');
@@ -78,6 +79,37 @@ for (let i = 0; i < process.execArgv.length; i++) {
7879

7980
ArrayPrototypePushApply(argsWithoutWatchOptions, kCommand);
8081

82+
// Strip watch-related flags from NODE_OPTIONS to prevent infinite loop
83+
// when NODE_OPTIONS contains --watch (see issue #61740).
84+
const kNodeOptions = process.env.NODE_OPTIONS;
85+
let cleanNodeOptions = kNodeOptions;
86+
if (kNodeOptions != null) {
87+
const keep = [];
88+
const parts = parseNodeOptionsEnvVar(kNodeOptions);
89+
for (let i = 0; i < parts.length; i++) {
90+
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=')) {
97+
continue;
98+
}
99+
if (part === '--watch-path' || part === '--watch-kill-signal') {
100+
// Skip the flag and its separate value argument
101+
i++;
102+
continue;
103+
}
104+
// The C++ tokenizer strips quotes during parsing, so values that
105+
// originally contained spaces (e.g. --require "./path with spaces/f.js")
106+
// need to be re-quoted before rejoining into a single string, otherwise
107+
// the child's C++ parser would split them into separate tokens.
108+
ArrayPrototypePush(keep, StringPrototypeIncludes(part, ' ') ? `"${part}"` : part);
109+
}
110+
cleanNodeOptions = ArrayPrototypeJoin(keep, ' ');
111+
}
112+
81113
const watcher = new FilesWatcher({ debounce: 200, mode: kShouldFilterModules ? 'filter' : 'all' });
82114
ArrayPrototypeForEach(kWatchedPaths, (p) => watcher.watchPath(p));
83115

@@ -96,6 +128,7 @@ function start() {
96128
env: {
97129
...process.env,
98130
WATCH_REPORT_DEPENDENCIES: '1',
131+
NODE_OPTIONS: cleanNodeOptions,
99132
},
100133
});
101134
watcher.watchChildProcessModules(child);

‎lib/internal/options.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
getEmbedderOptions: getEmbedderOptionsFromBinding,
1717
getEnvOptionsInputType,
1818
getNamespaceOptionsInputType,
19+
parseNodeOptionsEnvVar,
1920
} = internalBinding('options');
2021

2122
let warnOnAllowUnauthorized = true;
@@ -222,5 +223,6 @@ module.exports = {
222223
getAllowUnauthorized,
223224
getEmbedderOptions,
224225
generateConfigJsonSchema,
226+
parseNodeOptionsEnvVar,
225227
refreshOptions,
226228
};

‎src/node_options.cc‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2329,6 +2329,29 @@ void GetOptionsAsFlags(const FunctionCallbackInfo<Value>& args) {
23292329
args.GetReturnValue().Set(result);
23302330
}
23312331

2332+
void ParseNodeOptionsEnvVarBinding(const FunctionCallbackInfo<Value>& args) {
2333+
Isolate* isolate = args.GetIsolate();
2334+
Local<Context> context = isolate->GetCurrentContext();
2335+
2336+
Utf8Value node_options(isolate, args[0]);
2337+
std::string options_str(*node_options, node_options.length());
2338+
2339+
std::vector<std::string> errors;
2340+
std::vector<std::string> result =
2341+
ParseNodeOptionsEnvVar(options_str, &errors);
2342+
2343+
if (!errors.empty()) {
2344+
Environment* env = Environment::GetCurrent(context);
2345+
env->ThrowError(errors[0].c_str());
2346+
return;
2347+
}
2348+
2349+
Local<Value> v8_result;
2350+
if (ToV8Value(context, result).ToLocal(&v8_result)) {
2351+
args.GetReturnValue().Set(v8_result);
2352+
}
2353+
}
2354+
23322355
void Initialize(Local<Object> target,
23332356
Local<Value> unused,
23342357
Local<Context> context,
@@ -2349,6 +2372,8 @@ void Initialize(Local<Object> target,
23492372
target,
23502373
"getNamespaceOptionsInputType",
23512374
GetNamespaceOptionsInputType);
2375+
SetMethodNoSideEffect(
2376+
context, target, "parseNodeOptionsEnvVar", ParseNodeOptionsEnvVarBinding);
23522377
Local<Object> env_settings = Object::New(isolate);
23532378
NODE_DEFINE_CONSTANT(env_settings, kAllowedInEnvvar);
23542379
NODE_DEFINE_CONSTANT(env_settings, kDisallowedInEnvvar);
@@ -2377,6 +2402,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
23772402
registry->Register(GetEmbedderOptions);
23782403
registry->Register(GetEnvOptionsInputType);
23792404
registry->Register(GetNamespaceOptionsInputType);
2405+
registry->Register(ParseNodeOptionsEnvVarBinding);
23802406
}
23812407
} // namespace options_parser
23822408

‎test/parallel/test-options-binding.js‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
const common = require('../common');
55
const assert = require('assert');
6-
const { getOptionValue } = require('internal/options');
6+
const { getOptionValue, parseNodeOptionsEnvVar } = require('internal/options');
77

88
Map.prototype.get =
99
common.mustNotCall('`getOptionValue` must not call user-mutable method');
@@ -14,3 +14,30 @@ assert.strictEqual(getOptionValue('--nonexistent-option'), undefined);
1414

1515
// Make the test common global leak test happy.
1616
delete Object.prototype['--nonexistent-option'];
17+
18+
// parseNodeOptionsEnvVar tokenizes a NODE_OPTIONS-style string.
19+
assert.deepStrictEqual(
20+
parseNodeOptionsEnvVar('--max-old-space-size=4096 --no-warnings'),
21+
['--max-old-space-size=4096', '--no-warnings']
22+
);
23+
24+
// Quoted strings are unquoted during parsing.
25+
assert.deepStrictEqual(
26+
parseNodeOptionsEnvVar('--require "file with spaces.js"'),
27+
['--require', 'file with spaces.js']
28+
);
29+
30+
// Empty string returns an empty array.
31+
assert.deepStrictEqual(parseNodeOptionsEnvVar(''), []);
32+
33+
// Throws on unterminated string.
34+
assert.throws(
35+
() => parseNodeOptionsEnvVar('--require "unterminated'),
36+
{ name: 'Error', message: /unterminated string/ }
37+
);
38+
39+
// Throws on invalid escape at end of string.
40+
assert.throws(
41+
() => parseNodeOptionsEnvVar('--require "foo\\'),
42+
{ name: 'Error', message: /invalid escape/ }
43+
);

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

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as common from '../common/index.mjs';
22
import tmpdir from '../common/tmpdir.js';
33
import assert from 'node:assert';
4+
import os from 'node:os';
45
import path from 'node:path';
56
import { execPath } from 'node:process';
67
import { describe, it } from 'node:test';
@@ -993,4 +994,112 @@ process.on('message', (message) => {
993994
await done();
994995
}
995996
});
997+
998+
it('should strip all watch flags from NODE_OPTIONS in child process', async () => {
999+
const file = createTmpFile('console.log(process.env.NODE_OPTIONS);');
1000+
const nodeOptions = [
1001+
'--watch',
1002+
'--watch=true',
1003+
'--watch-path=./src',
1004+
'--watch-path', './test',
1005+
'--watch-preserve-output',
1006+
'--watch-preserve-output=true',
1007+
'--watch-kill-signal=SIGKILL',
1008+
'--watch-kill-signal', 'SIGINT',
1009+
'--max-old-space-size=4096',
1010+
'--no-warnings',
1011+
].join(' ');
1012+
const { done, restart } = runInBackground({
1013+
args: ['--watch', file],
1014+
options: {
1015+
env: { ...process.env, NODE_OPTIONS: nodeOptions },
1016+
},
1017+
});
1018+
1019+
try {
1020+
const { stdout, stderr } = await restart();
1021+
1022+
assert.strictEqual(stderr, '');
1023+
const nodeOptionsLine = stdout.find((line) => line.includes('--max-old-space-size'));
1024+
assert.ok(nodeOptionsLine);
1025+
assert.strictEqual(nodeOptionsLine, '--max-old-space-size=4096 --no-warnings');
1026+
} finally {
1027+
await done();
1028+
}
1029+
});
1030+
1031+
it('should not strip --watch when it appears inside a quoted NODE_OPTIONS value', {
1032+
// Honoring --require from NODE_OPTIONS is required for this test.
1033+
skip: !!process.config.variables.node_without_node_options,
1034+
}, async () => {
1035+
// Use /tmp to avoid CI directories with special characters (e.g. ")
1036+
// that would break NODE_OPTIONS parsing.
1037+
const watchDir = path.join(os.tmpdir(), 'test for --watch parsing');
1038+
mkdirSync(watchDir, { recursive: true });
1039+
const reqFile = path.join(watchDir, 'req.cjs');
1040+
writeFileSync(reqFile, 'globalThis.requiredOk = true;');
1041+
1042+
const file = createTmpFile('console.log("required:" + !!globalThis.requiredOk);');
1043+
const nodeOptions = `--watch --require "${reqFile}"`;
1044+
const { done, restart } = runInBackground({
1045+
args: ['--watch', file],
1046+
options: {
1047+
env: { ...process.env, NODE_OPTIONS: nodeOptions },
1048+
},
1049+
});
1050+
1051+
try {
1052+
const { stdout, stderr } = await restart();
1053+
1054+
assert.strictEqual(stderr, '');
1055+
assert.ok(stdout.some((line) => line.includes('required:true')));
1056+
} finally {
1057+
await done();
1058+
}
1059+
});
1060+
1061+
it('should handle NODE_OPTIONS containing only watch flags', async () => {
1062+
const file = createTmpFile('console.log(JSON.stringify(process.env.NODE_OPTIONS));');
1063+
const { done, restart } = runInBackground({
1064+
args: ['--watch', file],
1065+
options: {
1066+
env: { ...process.env, NODE_OPTIONS: '--watch' },
1067+
},
1068+
});
1069+
1070+
try {
1071+
const { stdout, stderr } = await restart();
1072+
1073+
assert.strictEqual(stderr, '');
1074+
assert.ok(stdout.some((line) => line.includes('""')));
1075+
} finally {
1076+
await done();
1077+
}
1078+
});
1079+
1080+
it('should strip multiple --watch-path entries from NODE_OPTIONS', async () => {
1081+
const file = createTmpFile('console.log(process.env.NODE_OPTIONS);');
1082+
// Use /tmp to avoid CI directories with special characters (e.g. ")
1083+
// that would break NODE_OPTIONS parsing.
1084+
const dirA = path.join(os.tmpdir(), 'node-watch-path-a');
1085+
const dirB = path.join(os.tmpdir(), 'node-watch-path-b');
1086+
mkdirSync(dirA, { recursive: true });
1087+
mkdirSync(dirB, { recursive: true });
1088+
const nodeOptions = `--watch --watch-path=${dirA} --watch-path ${dirB} --no-warnings`;
1089+
const { done, restart } = runInBackground({
1090+
args: ['--watch', file],
1091+
options: {
1092+
env: { ...process.env, NODE_OPTIONS: nodeOptions },
1093+
},
1094+
});
1095+
1096+
try {
1097+
const { stdout, stderr } = await restart();
1098+
1099+
assert.strictEqual(stderr, '');
1100+
assert.ok(stdout.some((line) => line === '--no-warnings'));
1101+
} finally {
1102+
await done();
1103+
}
1104+
});
9961105
});

0 commit comments

Comments
 (0)