Repository: nodejs/node
Pull request: watch: reload changes in contents of --env-file

Make sure we watch and reload on env file changes.
Ignore env file in parent process, so child process can reload current vars when we recreate it.

Fixes: https://github.com/nodejs/node/issues/54001

I've noticed now that it's probably a duplicate of https://github.com/nodejs/node/pull/54033.

Feel free to close or reuse tests from here in the other PR if you plan on merging it.
It was a nice small bug to get feet wet with Node internals anyway. 👋

Changed files:
filename: lib/internal/main/watch_mode.js
patch: @@ -33,6 +33,7 @@ markBootstrapComplete();
 // TODO(MoLow): Make kill signal configurable
 const kKillSignal = 'SIGTERM';
 const kShouldFilterModules = getOptionValue('--watch-path').length === 0;
+const kEnvFile = getOptionValue('--env-file');
 const kWatchedPaths = ArrayPrototypeMap(getOptionValue('--watch-path'), (path) => resolve(path));
 const kPreserveOutput = getOptionValue('--watch-preserve-output');
 const kCommand = ArrayPrototypeSlice(process.argv, 1);
@@ -73,6 +74,9 @@ function start() {
     },
   });
   watcher.watchChildProcessModules(child);
+  if (kEnvFile) {
+    watcher.filterFile(resolve(kEnvFile));
+  }
   child.once('exit', (code) => {
     exited = true;
     if (code === 0) {

filename: src/node.cc
patch: @@ -330,7 +330,10 @@ MaybeLocal<Value> StartExecution(Environment* env, StartExecutionCallback cb) {
   }
 #endif
 
-  if (env->options()->has_env_file_string) {
+  // Ignore env file if we're in watch mode.
+  // Without it env is not updated when restarting child process.
+  // Child process has --watch flag removed, so it will load the file.
+  if (env->options()->has_env_file_string && !env->options()->watch_mode) {
     per_process::dotenv_file.SetEnvironment(env);
   }
 

filename: test/sequential/test-watch-mode.mjs
patch: @@ -30,6 +30,66 @@ function createTmpFile(content = 'console.log("running");', ext = '.js', basenam
   return file;
 }
 
+function runInBackground({ args = [], options = {}, completed = 'Completed running', shouldFail = false }) {
+  let future = Promise.withResolvers();
+  let child;
+  let stderr = '';
+  let stdout = [];
+
+  const run = () => {
+    args.unshift('--no-warnings');
+    child = spawn(execPath, args, { encoding: 'utf8', stdio: 'pipe', ...options });
+
+    child.stderr.on('data', (data) => {
+      stderr += data;
+    });
+
+    const rl = createInterface({ input: child.stdout });
+    rl.on('line', (data) => {
+      if (!data.startsWith('Waiting for graceful termination') && !data.startsWith('Gracefully restarted')) {
+        stdout.push(data);
+        if (data.startsWith(completed)) {
+          future.resolve({ stderr, stdout });
+          future = Promise.withResolvers();
+          stdout = [];
+          stderr = '';
+        } else if (data.startsWith('Failed running')) {
+          if (shouldFail) {
+            future.resolve({ stderr, stdout });
+          } else {
+            future.reject({ stderr, stdout });
+          }
+          future = Promise.withResolvers();
+          stdout = [];
+          stderr = '';
+        }
+      }
+    });
+  };
+
+  return {
+    async done() {
+      child?.kill();
+      future.resolve();
+      return { stdout, stderr };
+    },
+    restart(timeout = 1000) {
+      if (!child) {
+        run();
+      }
+      const timer = setTimeout(() => {
+        if (!future.resolved) {
+          child.kill();
+          future.reject(new Error('Timed out waiting for restart'));
+        }
+      }, timeout);
+      return future.promise.finally(() => {
+        clearTimeout(timer);
+      });
+    }
+  };
+}
+
 async function runWriteSucceed({
   file,
   watchedFile,
@@ -132,6 +192,56 @@ describe('watch mode', { concurrency: !process.env.TEST_PARALLEL, timeout: 60_00
     ]);
   });
 
+  it('should reload env variables when --env-file changes', async () => {
+    const envKey = `TEST_ENV_${Date.now()}`;
+    const jsFile = createTmpFile(`console.log('ENV: ' + process.env.${envKey});`);
+    const envFile = createTmpFile(`${envKey}=value1`, '.env');
+    const { done, restart } = runInBackground({ args: ['--watch', `--env-file=${envFile}`, jsFile] });
+
+    try {
+      await restart();
+      writeFileSync(envFile, `${envKey}=value2`);
+
+      // Second restart, after env change
+      const { stdout, stderr } = await restart();
+
+      assert.strictEqual(stderr, '');
+      assert.deepStrictEqual(stdout, [
+        `Restarting ${inspect(jsFile)}`,
+        'ENV: value2',
+        `Completed running ${inspect(jsFile)}`,
+      ]);
+    } finally {
+      await done();
+    }
+  });
+
+  it('should load new env variables when --env-file changes', async () => {
+    const envKey = `TEST_ENV_${Date.now()}`;
+    const envKey2 = `TEST_ENV_2_${Date.now()}`;
+    const jsFile = createTmpFile(`console.log('ENV: ' + process.env.${envKey} + '\\n' + 'ENV2: ' + process.env.${envKey2});`);
+    const envFile = createTmpFile(`${envKey}=value1`, '.env');
+    const { done, restart } = runInBackground({ args: ['--watch', `--env-file=${envFile}`, jsFile] });
+
+    try {
+      await restart();
+      await writeFileSync(envFile, `${envKey}=value1\n${envKey2}=newValue`);
+
+      // Second restart, after env change
+      const { stderr, stdout } = await restart();
+
+      assert.strictEqual(stderr, '');
+      assert.deepStrictEqual(stdout, [
+        `Restarting ${inspect(jsFile)}`,
+        'ENV: value1',
+        'ENV2: newValue',
+        `Completed running ${inspect(jsFile)}`,
+      ]);
+    } finally {
+      await done();
+    }
+  });
+
   it('should watch changes to a failing file', async () => {
     const file = createTmpFile('throw new Error("fails");');
     const { stderr, stdout } = await runWriteSucceed({