cli: add --watch

PR-URL: https://github.com/nodejs/node/pull/44366
Fixes: https://github.com/nodejs/node/issues/40429
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
This commit is contained in:
Moshe Atlow 2022-08-23 20:50:21 +03:00
parent 50a413183e
commit beb0520af7
29 changed files with 954 additions and 42 deletions

View File

@ -1577,6 +1577,53 @@ on the number of online processors.
If the value provided is larger than V8's maximum, then the largest value
will be chosen.
### `--watch`
<!-- YAML
added: REPLACEME
-->
> Stability: 1 - Experimental
Starts Node.js in watch mode.
When in watch mode, changes in the watched files cause the Node.js process to
restart.
By default, watch mode will watch the entry point
and any required or imported module.
Use `--watch-path` to specify what paths to watch.
This flag cannot be combined with
`--check`, `--eval`, `--interactive`, or the REPL.
```console
$ node --watch index.js
```
### `--watch-path`
<!-- YAML
added: REPLACEME
-->
> Stability: 1 - Experimental
Starts Node.js in watch mode and specifies what paths to watch.
When in watch mode, changes in the watched paths cause the Node.js process to
restart.
This will turn off watching of required or imported modules, even when used in
combination with `--watch`.
This flag cannot be combined with
`--check`, `--eval`, `--interactive`, or the REPL.
```console
$ node --watch-path=./src --watch-path=./tests index.js
```
This option is only supported on macOS and Windows.
An `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` exception will be thrown
when the option is used on a platform that does not support it.
### `--zero-fill-buffers`
<!-- YAML
@ -1880,6 +1927,8 @@ Node.js options that are allowed are:
* `--use-largepages`
* `--use-openssl-ca`
* `--v8-pool-size`
* `--watch-path`
* `--watch`
* `--zero-fill-buffers`
<!-- node-options-node end -->

View File

@ -21,15 +21,12 @@ const { inspect } = require('internal/util/inspect');
const {
removeColors,
} = require('internal/util');
const colors = require('internal/util/colors');
const {
validateObject,
} = require('internal/validators');
const { isErrorStackTraceLimitWritable } = require('internal/errors');
let blue = '';
let green = '';
let red = '';
let white = '';
const kReadableOperator = {
deepStrictEqual: 'Expected values to be strictly deep-equal:',
@ -169,7 +166,7 @@ function createErrDiff(actual, expected, operator) {
// Only remove lines in case it makes sense to collapse those.
// TODO: Accept env to always show the full error.
if (actualLines.length > 50) {
actualLines[46] = `${blue}...${white}`;
actualLines[46] = `${colors.blue}...${colors.white}`;
while (actualLines.length > 47) {
ArrayPrototypePop(actualLines);
}
@ -182,7 +179,7 @@ function createErrDiff(actual, expected, operator) {
// There were at least five identical lines at the end. Mark a couple of
// skipped.
if (i >= 5) {
end = `\n${blue}...${white}${end}`;
end = `\n${colors.blue}...${colors.white}${end}`;
skipped = true;
}
if (other !== '') {
@ -193,15 +190,15 @@ function createErrDiff(actual, expected, operator) {
let printedLines = 0;
let identical = 0;
const msg = kReadableOperator[operator] +
`\n${green}+ actual${white} ${red}- expected${white}`;
const skippedMsg = ` ${blue}...${white} Lines skipped`;
`\n${colors.green}+ actual${colors.white} ${colors.red}- expected${colors.white}`;
const skippedMsg = ` ${colors.blue}...${colors.white} Lines skipped`;
let lines = actualLines;
let plusMinus = `${green}+${white}`;
let plusMinus = `${colors.green}+${colors.white}`;
let maxLength = expectedLines.length;
if (actualLines.length < maxLines) {
lines = expectedLines;
plusMinus = `${red}-${white}`;
plusMinus = `${colors.red}-${colors.white}`;
maxLength = actualLines.length;
}
@ -216,7 +213,7 @@ function createErrDiff(actual, expected, operator) {
res += `\n ${lines[i - 3]}`;
printedLines++;
} else {
res += `\n${blue}...${white}`;
res += `\n${colors.blue}...${colors.white}`;
skipped = true;
}
}
@ -272,7 +269,7 @@ function createErrDiff(actual, expected, operator) {
res += `\n ${actualLines[i - 3]}`;
printedLines++;
} else {
res += `\n${blue}...${white}`;
res += `\n${colors.blue}...${colors.white}`;
skipped = true;
}
}
@ -286,8 +283,8 @@ function createErrDiff(actual, expected, operator) {
identical = 0;
// Add the actual line to the result and cache the expected diverging
// line so consecutive diverging lines show up as +++--- and not +-+-+-.
res += `\n${green}+${white} ${actualLine}`;
other += `\n${red}-${white} ${expectedLine}`;
res += `\n${colors.green}+${colors.white} ${actualLine}`;
other += `\n${colors.red}-${colors.white} ${expectedLine}`;
printedLines += 2;
// Lines are identical
} else {
@ -306,8 +303,8 @@ function createErrDiff(actual, expected, operator) {
}
// Inspected object to big (Show ~50 rows max)
if (printedLines > 50 && i < maxLines - 2) {
return `${msg}${skippedMsg}\n${res}\n${blue}...${white}${other}\n` +
`${blue}...${white}`;
return `${msg}${skippedMsg}\n${res}\n${colors.blue}...${colors.white}${other}\n` +
`${colors.blue}...${colors.white}`;
}
}
@ -347,21 +344,9 @@ class AssertionError extends Error {
if (message != null) {
super(String(message));
} else {
if (process.stderr.isTTY) {
// Reset on each call to make sure we handle dynamically set environment
// variables correct.
if (process.stderr.hasColors()) {
blue = '\u001b[34m';
green = '\u001b[32m';
white = '\u001b[39m';
red = '\u001b[31m';
} else {
blue = '';
green = '';
white = '';
red = '';
}
}
// Reset colors on each call to make sure we handle dynamically set environment
// variables correct.
colors.refresh();
// Prevent the error stack from being visible by duplicating the error
// in a very close way to the original in case both sides are actually
// instances of Error.
@ -393,7 +378,7 @@ class AssertionError extends Error {
// Only remove lines in case it makes sense to collapse those.
// TODO: Accept env to always show the full error.
if (res.length > 50) {
res[46] = `${blue}...${white}`;
res[46] = `${colors.blue}...${colors.white}`;
while (res.length > 47) {
ArrayPrototypePop(res);
}

View File

@ -0,0 +1,132 @@
'use strict';
const {
ArrayPrototypeFilter,
ArrayPrototypeForEach,
ArrayPrototypeJoin,
ArrayPrototypeMap,
ArrayPrototypePushApply,
ArrayPrototypeSlice,
} = primordials;
const {
prepareMainThreadExecution,
markBootstrapComplete
} = require('internal/process/pre_execution');
const { triggerUncaughtException } = internalBinding('errors');
const { getOptionValue } = require('internal/options');
const { emitExperimentalWarning } = require('internal/util');
const { FilesWatcher } = require('internal/watch_mode/files_watcher');
const { green, blue, red, white, clear } = require('internal/util/colors');
const { spawn } = require('child_process');
const { inspect } = require('util');
const { setTimeout, clearTimeout } = require('timers');
const { resolve } = require('path');
const { once, on } = require('events');
prepareMainThreadExecution(false, false);
markBootstrapComplete();
// TODO(MoLow): Make kill signal configurable
const kKillSignal = 'SIGTERM';
const kShouldFilterModules = getOptionValue('--watch-path').length === 0;
const kWatchedPaths = ArrayPrototypeMap(getOptionValue('--watch-path'), (path) => resolve(path));
const kCommand = ArrayPrototypeSlice(process.argv, 1);
const kCommandStr = inspect(ArrayPrototypeJoin(kCommand, ' '));
const args = ArrayPrototypeFilter(process.execArgv, (arg, i, arr) =>
arg !== '--watch-path' && arr[i - 1] !== '--watch-path' && arg !== '--watch');
ArrayPrototypePushApply(args, kCommand);
const watcher = new FilesWatcher({ throttle: 500, mode: kShouldFilterModules ? 'filter' : 'all' });
ArrayPrototypeForEach(kWatchedPaths, (p) => watcher.watchPath(p));
let graceTimer;
let child;
let exited;
function start() {
exited = false;
const stdio = kShouldFilterModules ? ['inherit', 'inherit', 'inherit', 'ipc'] : undefined;
child = spawn(process.execPath, args, { stdio, env: { ...process.env, WATCH_REPORT_DEPENDENCIES: '1' } });
watcher.watchChildProcessModules(child);
child.once('exit', (code) => {
exited = true;
if (code === 0) {
process.stdout.write(`${blue}Completed running ${kCommandStr}${white}\n`);
} else {
process.stdout.write(`${red}Failed running ${kCommandStr}${white}\n`);
}
});
}
async function killAndWait(signal = kKillSignal, force = false) {
child?.removeAllListeners();
if (!child) {
return;
}
if ((child.killed || exited) && !force) {
return;
}
const onExit = once(child, 'exit');
child.kill(signal);
const { 0: exitCode } = await onExit;
return exitCode;
}
function reportGracefulTermination() {
// Log if process takes more than 500ms to stop.
let reported = false;
clearTimeout(graceTimer);
graceTimer = setTimeout(() => {
reported = true;
process.stdout.write(`${blue}Waiting for graceful termination...${white}\n`);
}, 500).unref();
return () => {
clearTimeout(graceTimer);
if (reported) {
process.stdout.write(`${clear}${green}Gracefully restarted ${kCommandStr}${white}\n`);
}
};
}
async function stop() {
watcher.clearFileFilters();
const clearGraceReport = reportGracefulTermination();
await killAndWait();
clearGraceReport();
}
async function restart() {
process.stdout.write(`${clear}${green}Restarting ${kCommandStr}${white}\n`);
await stop();
start();
}
(async () => {
emitExperimentalWarning('Watch mode');
try {
start();
// eslint-disable-next-line no-unused-vars
for await (const _ of on(watcher, 'changed')) {
await restart();
}
} catch (error) {
triggerUncaughtException(error, true /* fromPromise */);
}
})();
// Exiting gracefully to avoid stdout/stderr getting written after
// parent process is killed.
// this is fairly safe since user code cannot run in this process
function signalHandler(signal) {
return async () => {
watcher.clear();
const exitCode = await killAndWait(signal, true);
process.exit(exitCode ?? 0);
};
}
process.on('SIGTERM', signalHandler('SIGTERM'));
process.on('SIGINT', signalHandler('SIGINT'));

View File

@ -100,6 +100,7 @@ const {
const { getOptionValue } = require('internal/options');
const preserveSymlinks = getOptionValue('--preserve-symlinks');
const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main');
const shouldReportRequiredModules = process.env.WATCH_REPORT_DEPENDENCIES;
// Do not eagerly grab .manifest, it may be in TDZ
const policy = getOptionValue('--experimental-policy') ?
require('internal/process/policy') :
@ -168,6 +169,12 @@ function updateChildren(parent, child, scan) {
ArrayPrototypePush(children, child);
}
function reportModuleToWatchMode(filename) {
if (shouldReportRequiredModules && process.send) {
process.send({ 'watch:require': filename });
}
}
const moduleParentCache = new SafeWeakMap();
function Module(id = '', parent) {
this.id = id;
@ -776,6 +783,7 @@ Module._load = function(request, parent, isMain) {
// cache key names.
relResolveCacheIdentifier = `${parent.path}\x00${request}`;
const filename = relativeResolveCache[relResolveCacheIdentifier];
reportModuleToWatchMode(filename);
if (filename !== undefined) {
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
@ -828,6 +836,8 @@ Module._load = function(request, parent, isMain) {
module.id = '.';
}
reportModuleToWatchMode(filename);
Module._cache[filename] = module;
if (parent !== undefined) {
relativeResolveCache[relResolveCacheIdentifier] = filename;

View File

@ -474,6 +474,10 @@ class ESMLoader {
getOptionValue('--inspect-brk')
);
if (process.env.WATCH_REPORT_DEPENDENCIES && process.send) {
process.send({ 'watch:import': url });
}
const job = new ModuleJob(
this,
url,

View File

@ -0,0 +1,23 @@
'use strict';
module.exports = {
blue: '',
green: '',
white: '',
red: '',
clear: '',
hasColors: false,
refresh() {
if (process.stderr.isTTY) {
const hasColors = process.stderr.hasColors();
module.exports.blue = hasColors ? '\u001b[34m' : '';
module.exports.green = hasColors ? '\u001b[32m' : '';
module.exports.white = hasColors ? '\u001b[39m' : '';
module.exports.red = hasColors ? '\u001b[31m' : '';
module.exports.clear = hasColors ? '\u001bc' : '';
module.exports.hasColors = hasColors;
}
}
};
module.exports.refresh();

View File

@ -0,0 +1,133 @@
'use strict';
const {
SafeMap,
SafeSet,
StringPrototypeStartsWith,
} = primordials;
const { validateNumber, validateOneOf } = require('internal/validators');
const { kEmptyObject } = require('internal/util');
const { TIMEOUT_MAX } = require('internal/timers');
const EventEmitter = require('events');
const { watch } = require('fs');
const { fileURLToPath } = require('url');
const { resolve, dirname } = require('path');
const { setTimeout } = require('timers');
const supportsRecursiveWatching = process.platform === 'win32' ||
process.platform === 'darwin';
class FilesWatcher extends EventEmitter {
#watchers = new SafeMap();
#filteredFiles = new SafeSet();
#throttling = new SafeSet();
#throttle;
#mode;
constructor({ throttle = 500, mode = 'filter' } = kEmptyObject) {
super();
validateNumber(throttle, 'options.throttle', 0, TIMEOUT_MAX);
validateOneOf(mode, 'options.mode', ['filter', 'all']);
this.#throttle = throttle;
this.#mode = mode;
}
#isPathWatched(path) {
if (this.#watchers.has(path)) {
return true;
}
for (const { 0: watchedPath, 1: watcher } of this.#watchers.entries()) {
if (watcher.recursive && StringPrototypeStartsWith(path, watchedPath)) {
return true;
}
}
return false;
}
#removeWatchedChildren(path) {
for (const { 0: watchedPath, 1: watcher } of this.#watchers.entries()) {
if (path !== watchedPath && StringPrototypeStartsWith(watchedPath, path)) {
this.#unwatch(watcher);
this.#watchers.delete(watchedPath);
}
}
}
#unwatch(watcher) {
watcher.handle.removeAllListeners();
watcher.handle.close();
}
#onChange(trigger) {
if (this.#throttling.has(trigger)) {
return;
}
if (this.#mode === 'filter' && !this.#filteredFiles.has(trigger)) {
return;
}
this.#throttling.add(trigger);
this.emit('changed');
setTimeout(() => this.#throttling.delete(trigger), this.#throttle).unref();
}
get watchedPaths() {
return [...this.#watchers.keys()];
}
watchPath(path, recursive = true) {
if (this.#isPathWatched(path)) {
return;
}
const watcher = watch(path, { recursive });
watcher.on('change', (eventType, fileName) => this
.#onChange(recursive ? resolve(path, fileName) : path));
this.#watchers.set(path, { handle: watcher, recursive });
if (recursive) {
this.#removeWatchedChildren(path);
}
}
filterFile(file) {
if (supportsRecursiveWatching) {
this.watchPath(dirname(file));
} else {
// Having multiple FSWatcher's seems to be slower
// than a single recursive FSWatcher
this.watchPath(file, false);
}
this.#filteredFiles.add(file);
}
watchChildProcessModules(child) {
if (this.#mode !== 'filter') {
return;
}
child.on('message', (message) => {
try {
if (message['watch:require']) {
this.filterFile(message['watch:require']);
}
if (message['watch:import']) {
this.filterFile(fileURLToPath(message['watch:import']));
}
} catch {
// Failed watching file. ignore
}
});
}
clearFileFilters() {
this.#filteredFiles.clear();
}
clear() {
this.#watchers.forEach(this.#unwatch);
this.#watchers.clear();
this.#filteredFiles.clear();
}
}
module.exports = { FilesWatcher };

View File

@ -317,6 +317,12 @@
}],
],
}],
[ 'coverage=="true"', {
'defines': [
'ALLOW_ATTACHING_DEBUGGER_IN_WATCH_MODE',
'ALLOW_ATTACHING_DEBUGGER_IN_TEST_RUNNER',
],
}],
[ 'OS=="sunos"', {
'ldflags': [ '-Wl,-M,/usr/lib/ld/map.noexstk' ],
}],

View File

@ -648,7 +648,8 @@ inline bool Environment::owns_inspector() const {
}
inline bool Environment::should_create_inspector() const {
return (flags_ & EnvironmentFlags::kNoCreateInspector) == 0;
return (flags_ & EnvironmentFlags::kNoCreateInspector) == 0 &&
!options_->test_runner && !options_->watch_mode;
}
inline bool Environment::tracks_unmanaged_fds() const {

View File

@ -676,6 +676,9 @@ bool Agent::Start(const std::string& path,
const DebugOptions& options,
std::shared_ptr<ExclusiveAccess<HostPort>> host_port,
bool is_main) {
if (!options.allow_attaching_debugger) {
return false;
}
path_ = path;
debug_options_ = options;
CHECK_NOT_NULL(host_port);

View File

@ -340,6 +340,10 @@ MaybeLocal<Value> StartExecution(Environment* env, StartExecutionCallback cb) {
return StartExecution(env, "internal/main/test_runner");
}
if (env->options()->watch_mode && !first_argv.empty()) {
return StartExecution(env, "internal/main/watch_mode");
}
if (!first_argv.empty() && first_argv != "-") {
return StartExecution(env, "internal/main/run_main_module");
}

View File

@ -156,9 +156,36 @@ void EnvironmentOptions::CheckOptions(std::vector<std::string>* errors) {
errors->push_back("either --test or --interactive can be used, not both");
}
if (watch_mode) {
// TODO(MoLow): Support (incremental?) watch mode within test runner
errors->push_back("either --test or --watch can be used, not both");
}
if (debug_options_.inspector_enabled) {
errors->push_back("the inspector cannot be used with --test");
}
#ifndef ALLOW_ATTACHING_DEBUGGER_IN_TEST_RUNNER
debug_options_.allow_attaching_debugger = false;
#endif
}
if (watch_mode) {
if (syntax_check_only) {
errors->push_back("either --watch or --check can be used, not both");
}
if (has_eval_string) {
errors->push_back("either --watch or --eval can be used, not both");
}
if (force_repl) {
errors->push_back("either --watch or --interactive "
"can be used, not both");
}
#ifndef ALLOW_ATTACHING_DEBUGGER_IN_WATCH_MODE
debug_options_.allow_attaching_debugger = false;
#endif
}
#if HAVE_INSPECTOR
@ -586,7 +613,15 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
"", /* undocumented, only for debugging */
&EnvironmentOptions::verify_base_objects,
kAllowedInEnvironment);
AddOption("--watch",
"run in watch mode",
&EnvironmentOptions::watch_mode,
kAllowedInEnvironment);
AddOption("--watch-path",
"path to watch",
&EnvironmentOptions::watch_mode_paths,
kAllowedInEnvironment);
Implies("--watch-path", "--watch");
AddOption("--check",
"syntax check script without executing",
&EnvironmentOptions::syntax_check_only);

View File

@ -71,6 +71,7 @@ class DebugOptions : public Options {
DebugOptions(DebugOptions&&) = default;
DebugOptions& operator=(DebugOptions&&) = default;
bool allow_attaching_debugger = true;
// --inspect
bool inspector_enabled = false;
// --debug
@ -172,6 +173,10 @@ class EnvironmentOptions : public Options {
false;
#endif // DEBUG
bool watch_mode = false;
bool watch_mode_report_to_parent = false;
std::vector<std::string> watch_mode_paths;
bool syntax_check_only = false;
bool has_eval_string = false;
bool experimental_wasi = false;

View File

@ -151,6 +151,7 @@ class InspectorSession {
});
}
waitForServerDisconnect() {
return this._terminationPromise;
}
@ -326,13 +327,15 @@ class InspectorSession {
class NodeInstance extends EventEmitter {
constructor(inspectorFlags = ['--inspect-brk=0', '--expose-internals'],
scriptContents = '',
scriptFile = _MAINSCRIPT) {
scriptFile = _MAINSCRIPT,
logger = console) {
super();
this._logger = logger;
this._scriptPath = scriptFile;
this._script = scriptFile ? null : scriptContents;
this._portCallback = null;
this.portPromise = new Promise((resolve) => this._portCallback = resolve);
this.resetPort();
this._process = spawnChildProcess(inspectorFlags, scriptContents,
scriptFile);
this._running = true;
@ -342,7 +345,7 @@ class NodeInstance extends EventEmitter {
this._process.stdout.on('data', makeBufferingDataCallback(
(line) => {
this.emit('stdout', line);
console.log('[out]', line);
this._logger.log('[out]', line);
}));
this._process.stderr.on('data', makeBufferingDataCallback(
@ -351,7 +354,7 @@ class NodeInstance extends EventEmitter {
this._shutdownPromise = new Promise((resolve) => {
this._process.once('exit', (exitCode, signal) => {
if (signal) {
console.error(`[err] child process crashed, signal ${signal}`);
this._logger.error(`[err] child process crashed, signal ${signal}`);
}
resolve({ exitCode, signal });
this._running = false;
@ -359,6 +362,14 @@ class NodeInstance extends EventEmitter {
});
}
get pid() {
return this._process.pid;
}
resetPort() {
this.portPromise = new Promise((resolve) => this._portCallback = resolve);
}
static async startViaSignal(scriptContents) {
const instance = new NodeInstance(
['--expose-internals'],
@ -370,7 +381,8 @@ class NodeInstance extends EventEmitter {
}
onStderrLine(line) {
console.log('[err]', line);
this.emit('stderr', line);
this._logger.log('[err]', line);
if (this._portCallback) {
const matches = line.match(/Debugger listening on ws:\/\/.+:(\d+)\/.+/);
if (matches) {
@ -387,7 +399,7 @@ class NodeInstance extends EventEmitter {
}
httpGet(host, path, hostHeaderValue) {
console.log('[test]', `Testing ${path}`);
this._logger.log('[test]', `Testing ${path}`);
const headers = hostHeaderValue ? { 'Host': hostHeaderValue } : null;
return this.portPromise.then((port) => new Promise((resolve, reject) => {
const req = http.get({ host, port, family: 4, path, headers }, (res) => {
@ -428,7 +440,7 @@ class NodeInstance extends EventEmitter {
}
async connectInspectorSession() {
console.log('[test]', 'Connecting to a child Node process');
this._logger.log('[test]', 'Connecting to a child Node process');
const upgradeRequest = await this.sendUpgradeRequest();
return new Promise((resolve) => {
upgradeRequest
@ -439,7 +451,7 @@ class NodeInstance extends EventEmitter {
}
async expectConnectionDeclined() {
console.log('[test]', 'Checking upgrade is not possible');
this._logger.log('[test]', 'Checking upgrade is not possible');
const upgradeRequest = await this.sendUpgradeRequest();
return new Promise((resolve) => {
upgradeRequest

2
test/fixtures/watch-mode/dependant.js vendored Normal file
View File

@ -0,0 +1,2 @@
const dependency = require('./dependency');
console.log(dependency);

View File

@ -0,0 +1,2 @@
import dependency from './dependency.mjs';
console.log(dependency);

View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -0,0 +1 @@
export default {};

1
test/fixtures/watch-mode/failing.js vendored Normal file
View File

@ -0,0 +1 @@
throw new Error('fails');

View File

@ -0,0 +1,17 @@
setInterval(() => {}, 1000);
console.log('running');
process.on('SIGTERM', () => {
setTimeout(() => {
console.log('exiting gracefully');
process.exit(0);
}, 1000);
});
process.on('SIGINT', () => {
setTimeout(() => {
console.log('exiting gracefully');
process.exit(0);
}, 1000);
});

View File

@ -0,0 +1,2 @@
console.log('running');
while(true) {};

2
test/fixtures/watch-mode/inspect.js vendored Normal file
View File

@ -0,0 +1,2 @@
console.log('safe to debug now');
setInterval(() => {}, 1000);

View File

@ -0,0 +1,2 @@
console.log('pid is', process.pid);
setInterval(() => {}, 1000);

12
test/fixtures/watch-mode/ipc.js vendored Normal file
View File

@ -0,0 +1,12 @@
const path = require('node:path');
const url = require('node:url');
const os = require('node:os');
const fs = require('node:fs');
const tmpfile = path.join(os.tmpdir(), 'file');
fs.writeFileSync(tmpfile, '');
process.send({ 'watch:require': path.resolve(__filename) });
process.send({ 'watch:import': url.pathToFileURL(path.resolve(__filename)).toString() });
process.send({ 'watch:import': url.pathToFileURL(tmpfile).toString() });
process.send({ 'watch:import': new URL('http://invalid.com').toString() });

View File

@ -0,0 +1,4 @@
const { parseArgs } = require('node:util');
const { values } = parseArgs({ options: { random: { type: 'string' } } });
console.log(values.random);

View File

@ -0,0 +1 @@
setImmediate(() => process.exit(0));

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,162 @@
// Flags: --expose-internals
import * as common from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import tmpdir from '../common/tmpdir.js';
import path from 'node:path';
import assert from 'node:assert';
import process from 'node:process';
import os from 'node:os';
import { describe, it, beforeEach, afterEach } from 'node:test';
import { writeFileSync, mkdirSync } from 'node:fs';
import { setTimeout } from 'node:timers/promises';
import { once } from 'node:events';
import { spawn } from 'node:child_process';
import watcher from 'internal/watch_mode/files_watcher';
if (common.isIBMi)
common.skip('IBMi does not support `fs.watch()`');
const supportsRecursiveWatching = common.isOSX || common.isWindows;
const { FilesWatcher } = watcher;
tmpdir.refresh();
describe('watch mode file watcher', () => {
let watcher;
let changesCount;
beforeEach(() => {
changesCount = 0;
watcher = new FilesWatcher({ throttle: 100 });
watcher.on('changed', () => changesCount++);
});
afterEach(() => watcher.clear());
let counter = 0;
function writeAndWaitForChanges(watcher, file) {
return new Promise((resolve) => {
const interval = setInterval(() => writeFileSync(file, `write ${counter++}`), 100);
watcher.once('changed', () => {
clearInterval(interval);
resolve();
});
});
}
it('should watch changed files', async () => {
const file = path.join(tmpdir.path, 'file1');
writeFileSync(file, 'written');
watcher.filterFile(file);
await writeAndWaitForChanges(watcher, file);
assert.strictEqual(changesCount, 1);
});
it('should throttle changes', async () => {
const file = path.join(tmpdir.path, 'file2');
writeFileSync(file, 'written');
watcher.filterFile(file);
await writeAndWaitForChanges(watcher, file);
writeFileSync(file, '1');
writeFileSync(file, '2');
writeFileSync(file, '3');
writeFileSync(file, '4');
await setTimeout(200); // throttle * 2
writeFileSync(file, '5');
const changed = once(watcher, 'changed');
writeFileSync(file, 'after');
await changed;
// Unfortunately testing that changesCount === 2 is flaky
assert.ok(changesCount < 5);
});
it('should ignore files in watched directory if they are not filtered',
{ skip: !supportsRecursiveWatching }, async () => {
watcher.on('changed', common.mustNotCall());
watcher.watchPath(tmpdir.path);
writeFileSync(path.join(tmpdir.path, 'file3'), '1');
// Wait for this long to make sure changes are not triggered
await setTimeout(1000);
});
it('should allow clearing filters', async () => {
const file = path.join(tmpdir.path, 'file4');
writeFileSync(file, 'written');
watcher.filterFile(file);
await writeAndWaitForChanges(watcher, file);
writeFileSync(file, '1');
await setTimeout(200); // avoid throttling
watcher.clearFileFilters();
writeFileSync(file, '2');
// Wait for this long to make sure changes are triggered only once
await setTimeout(1000);
assert.strictEqual(changesCount, 1);
});
it('should watch all files in watched path when in "all" mode',
{ skip: !supportsRecursiveWatching }, async () => {
watcher = new FilesWatcher({ throttle: 100, mode: 'all' });
watcher.on('changed', () => changesCount++);
const file = path.join(tmpdir.path, 'file5');
watcher.watchPath(tmpdir.path);
const changed = once(watcher, 'changed');
writeFileSync(file, 'changed');
await changed;
assert.strictEqual(changesCount, 1);
});
it('should ruse existing watcher if it exists',
{ skip: !supportsRecursiveWatching }, () => {
assert.deepStrictEqual(watcher.watchedPaths, []);
watcher.watchPath(tmpdir.path);
assert.deepStrictEqual(watcher.watchedPaths, [tmpdir.path]);
watcher.watchPath(tmpdir.path);
assert.deepStrictEqual(watcher.watchedPaths, [tmpdir.path]);
});
it('should ruse existing watcher of a parent directory',
{ skip: !supportsRecursiveWatching }, () => {
assert.deepStrictEqual(watcher.watchedPaths, []);
watcher.watchPath(tmpdir.path);
assert.deepStrictEqual(watcher.watchedPaths, [tmpdir.path]);
watcher.watchPath(path.join(tmpdir.path, 'subdirectory'));
assert.deepStrictEqual(watcher.watchedPaths, [tmpdir.path]);
});
it('should remove existing watcher if adding a parent directory watcher',
{ skip: !supportsRecursiveWatching }, () => {
assert.deepStrictEqual(watcher.watchedPaths, []);
const subdirectory = path.join(tmpdir.path, 'subdirectory');
mkdirSync(subdirectory);
watcher.watchPath(subdirectory);
assert.deepStrictEqual(watcher.watchedPaths, [subdirectory]);
watcher.watchPath(tmpdir.path);
assert.deepStrictEqual(watcher.watchedPaths, [tmpdir.path]);
});
it('should clear all watchers when calling clear',
{ skip: !supportsRecursiveWatching }, () => {
assert.deepStrictEqual(watcher.watchedPaths, []);
watcher.watchPath(tmpdir.path);
assert.deepStrictEqual(watcher.watchedPaths, [tmpdir.path]);
watcher.clear();
assert.deepStrictEqual(watcher.watchedPaths, []);
});
it('should watch files from subprocess IPC events', async () => {
const file = fixtures.path('watch-mode/ipc.js');
const child = spawn(process.execPath, [file], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'], encoding: 'utf8' });
watcher.watchChildProcessModules(child);
await once(child, 'exit');
let expected = [file, path.join(os.tmpdir(), 'file')];
if (supportsRecursiveWatching) {
expected = expected.map((file) => path.dirname(file));
}
assert.deepStrictEqual(watcher.watchedPaths, expected);
});
});

View File

@ -0,0 +1,300 @@
import * as common from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import tmpdir from '../common/tmpdir.js';
import assert from 'node:assert';
import path from 'node:path';
import { execPath } from 'node:process';
import { describe, it } from 'node:test';
import { spawn } from 'node:child_process';
import { writeFileSync, readFileSync } from 'node:fs';
import { inspect } from 'node:util';
import { once } from 'node:events';
import { setTimeout } from 'node:timers/promises';
import { NodeInstance } from '../common/inspector-helper.js';
if (common.isIBMi)
common.skip('IBMi does not support `fs.watch()`');
async function spawnWithRestarts({
args,
file,
restarts,
startedPredicate,
restartMethod,
}) {
args ??= [file];
const printedArgs = inspect(args.slice(args.indexOf(file)).join(' '));
startedPredicate ??= (data) => Boolean(data.match(new RegExp(`(Failed|Completed) running ${printedArgs.replace(/\\/g, '\\\\')}`, 'g'))?.length);
restartMethod ??= () => writeFileSync(file, readFileSync(file));
let stderr = '';
let stdout = '';
let restartCount = 0;
let completedStart = false;
let finished = false;
const child = spawn(execPath, ['--watch', '--no-warnings', ...args], { encoding: 'utf8' });
child.stderr.on('data', (data) => {
stderr += data;
});
child.stdout.on('data', async (data) => {
if (finished) return;
stdout += data;
const restartMessages = stdout.match(new RegExp(`Restarting ${printedArgs.replace(/\\/g, '\\\\')}`, 'g'))?.length ?? 0;
completedStart = completedStart || startedPredicate(data.toString());
if (restartMessages >= restarts && completedStart) {
finished = true;
child.kill();
return;
}
if (restartCount <= restartMessages && completedStart) {
await setTimeout(restartCount > 0 ? 1000 : 50, { ref: false }); // Prevent throttling
restartCount++;
completedStart = false;
restartMethod();
}
});
await Promise.race([once(child, 'exit'), once(child, 'error')]);
return { stderr, stdout };
}
let tmpFiles = 0;
function createTmpFile(content = 'console.log("running");') {
const file = path.join(tmpdir.path, `${tmpFiles++}.js`);
writeFileSync(file, content);
return file;
}
function removeGraceMessage(stdout, file) {
// Remove the message in case restart took long to avoid flakiness
return stdout
.replaceAll('Waiting for graceful termination...', '')
.replaceAll(`Gracefully restarted ${inspect(file)}`, '');
}
tmpdir.refresh();
// Warning: this suite can run safely with concurrency: true
// only if tests do not watch/depend on the same files
describe('watch mode', { concurrency: false, timeout: 60_0000 }, () => {
it('should watch changes to a file - event loop ended', async () => {
const file = createTmpFile();
const { stderr, stdout } = await spawnWithRestarts({ file, restarts: 1 });
assert.strictEqual(stderr, '');
assert.strictEqual(removeGraceMessage(stdout, file), [
'running', `Completed running ${inspect(file)}`, `Restarting ${inspect(file)}`,
'running', `Completed running ${inspect(file)}`, '',
].join('\n'));
});
it('should watch changes to a failing file', async () => {
const file = fixtures.path('watch-mode/failing.js');
const { stderr, stdout } = await spawnWithRestarts({ file, restarts: 1 });
assert.match(stderr, /Error: fails\r?\n/);
assert.strictEqual(stderr.match(/Error: fails\r?\n/g).length, 2);
assert.strictEqual(removeGraceMessage(stdout, file), [`Failed running ${inspect(file)}`, `Restarting ${inspect(file)}`,
`Failed running ${inspect(file)}`, ''].join('\n'));
});
it('should not watch when running an non-existing file', async () => {
const file = fixtures.path('watch-mode/non-existing.js');
const { stderr, stdout } = await spawnWithRestarts({ file, restarts: 0, restartMethod: () => {} });
assert.match(stderr, /code: 'MODULE_NOT_FOUND'/);
assert.strictEqual(stdout, [`Failed running ${inspect(file)}`, ''].join('\n'));
});
it('should watch when running an non-existing file - when specified under --watch-path', {
skip: !common.isOSX && !common.isWindows
}, async () => {
const file = fixtures.path('watch-mode/subdir/non-existing.js');
const watched = fixtures.path('watch-mode/subdir/file.js');
const { stderr, stdout } = await spawnWithRestarts({
file,
args: ['--watch-path', fixtures.path('./watch-mode/subdir/'), file],
restarts: 1,
restartMethod: () => writeFileSync(watched, readFileSync(watched))
});
assert.strictEqual(stderr, '');
assert.strictEqual(removeGraceMessage(stdout, file), [`Failed running ${inspect(file)}`, `Restarting ${inspect(file)}`,
`Failed running ${inspect(file)}`, ''].join('\n'));
});
it('should watch changes to a file - event loop blocked', async () => {
const file = fixtures.path('watch-mode/infinite-loop.js');
const { stderr, stdout } = await spawnWithRestarts({
file,
restarts: 2,
startedPredicate: (data) => data.startsWith('running'),
});
assert.strictEqual(stderr, '');
assert.strictEqual(removeGraceMessage(stdout, file),
['running', `Restarting ${inspect(file)}`, 'running', `Restarting ${inspect(file)}`, 'running', ''].join('\n'));
});
it('should watch changes to dependencies - cjs', async () => {
const file = fixtures.path('watch-mode/dependant.js');
const dependency = fixtures.path('watch-mode/dependency.js');
const { stderr, stdout } = await spawnWithRestarts({
file,
restarts: 1,
restartMethod: () => writeFileSync(dependency, readFileSync(dependency)),
});
assert.strictEqual(stderr, '');
assert.strictEqual(removeGraceMessage(stdout, file), [
'{}', `Completed running ${inspect(file)}`, `Restarting ${inspect(file)}`,
'{}', `Completed running ${inspect(file)}`, '',
].join('\n'));
});
it('should watch changes to dependencies - esm', async () => {
const file = fixtures.path('watch-mode/dependant.mjs');
const dependency = fixtures.path('watch-mode/dependency.mjs');
const { stderr, stdout } = await spawnWithRestarts({
file,
restarts: 1,
restartMethod: () => writeFileSync(dependency, readFileSync(dependency)),
});
assert.strictEqual(stderr, '');
assert.strictEqual(removeGraceMessage(stdout, file), [
'{}', `Completed running ${inspect(file)}`, `Restarting ${inspect(file)}`,
'{}', `Completed running ${inspect(file)}`, '',
].join('\n'));
});
it('should restart multiple times', async () => {
const file = createTmpFile();
const { stderr, stdout } = await spawnWithRestarts({ file, restarts: 3 });
assert.strictEqual(stderr, '');
assert.strictEqual(stdout.match(new RegExp(`Restarting ${inspect(file).replace(/\\/g, '\\\\')}`, 'g')).length, 3);
});
it('should gracefully wait when restarting', { skip: common.isWindows }, async () => {
const file = fixtures.path('watch-mode/graceful-sigterm.js');
const { stderr, stdout } = await spawnWithRestarts({
file,
restarts: 1,
startedPredicate: (data) => data.startsWith('running'),
});
// This message appearing is very flaky depending on a race between the
// inner process and the outer process. it is acceptable for the message not to appear
// as long as the SIGTERM handler is respected.
if (stdout.includes('Waiting for graceful termination...')) {
assert.strictEqual(stdout, ['running', `Restarting ${inspect(file)}`, 'Waiting for graceful termination...',
'exiting gracefully', `Gracefully restarted ${inspect(file)}`, 'running', ''].join('\n'));
} else {
assert.strictEqual(stdout, ['running', `Restarting ${inspect(file)}`, 'exiting gracefully', 'running', ''].join('\n'));
}
assert.strictEqual(stderr, '');
});
it('should pass arguments to file', async () => {
const file = fixtures.path('watch-mode/parse_args.js');
const random = Date.now().toString();
const args = [file, '--random', random];
const { stderr, stdout } = await spawnWithRestarts({ file, args, restarts: 1 });
assert.strictEqual(stderr, '');
assert.strictEqual(removeGraceMessage(stdout, args.join(' ')), [
random, `Completed running ${inspect(args.join(' '))}`, `Restarting ${inspect(args.join(' '))}`,
random, `Completed running ${inspect(args.join(' '))}`, '',
].join('\n'));
});
it('should not load --require modules in main process', async () => {
const file = createTmpFile('');
const required = fixtures.path('watch-mode/process_exit.js');
const args = ['--require', required, file];
const { stderr, stdout } = await spawnWithRestarts({ file, args, restarts: 1 });
assert.strictEqual(stderr, '');
assert.strictEqual(removeGraceMessage(stdout, file), [
`Completed running ${inspect(file)}`, `Restarting ${inspect(file)}`, `Completed running ${inspect(file)}`, '',
].join('\n'));
});
it('should not load --import modules in main process', async () => {
const file = createTmpFile('');
const imported = fixtures.fileURL('watch-mode/process_exit.js');
const args = ['--import', imported, file];
const { stderr, stdout } = await spawnWithRestarts({ file, args, restarts: 1 });
assert.strictEqual(stderr, '');
assert.strictEqual(removeGraceMessage(stdout, file), [
`Completed running ${inspect(file)}`, `Restarting ${inspect(file)}`, `Completed running ${inspect(file)}`, '',
].join('\n'));
});
describe('inspect', {
skip: Boolean(process.config.variables.coverage || !process.features.inspector),
}, () => {
const silentLogger = { log: () => {}, error: () => {} };
async function getDebuggedPid(instance, waitForLog = true) {
const session = await instance.connectInspectorSession();
await session.send({ method: 'Runtime.enable' });
if (waitForLog) {
await session.waitForConsoleOutput('log', 'safe to debug now');
}
const { value: innerPid } = (await session.send({
'method': 'Runtime.evaluate', 'params': { 'expression': 'process.pid' }
})).result;
session.disconnect();
return innerPid;
}
it('should start debugger on inner process', async () => {
const file = fixtures.path('watch-mode/inspect.js');
const instance = new NodeInstance(['--inspect=0', '--watch'], undefined, file, silentLogger);
let stderr = '';
instance.on('stderr', (data) => { stderr += data; });
const pids = [instance.pid];
pids.push(await getDebuggedPid(instance));
instance.resetPort();
writeFileSync(file, readFileSync(file));
pids.push(await getDebuggedPid(instance));
await instance.kill();
// There should be 3 pids (one parent + 2 restarts).
// Message about Debugger should only appear twice.
assert.strictEqual(stderr.match(/Debugger listening on ws:\/\//g).length, 2);
assert.strictEqual(new Set(pids).size, 3);
});
it('should prevent attaching debugger with SIGUSR1 to outer process', { skip: common.isWindows }, async () => {
const file = fixtures.path('watch-mode/inspect_with_signal.js');
const instance = new NodeInstance(['--inspect-port=0', '--watch'], undefined, file, silentLogger);
let stderr = '';
instance.on('stderr', (data) => { stderr += data; });
const loggedPid = await new Promise((resolve) => {
instance.on('stdout', (data) => {
const matches = data.match(/pid is (\d+)/);
if (matches) resolve(Number(matches[1]));
});
});
process.kill(instance.pid, 'SIGUSR1');
process.kill(loggedPid, 'SIGUSR1');
const debuggedPid = await getDebuggedPid(instance, false);
await instance.kill();
// Message about Debugger should only appear once in inner process.
assert.strictEqual(stderr.match(/Debugger listening on ws:\/\//g).length, 1);
assert.strictEqual(loggedPid, debuggedPid);
});
});
});