Skip to content

Broken HMR via signal #56

Description

@7rulnik

Hello, thanks for this fork. We have been using it for years without any problems.

Recently, we decided to enable HMR in our project, and I realized that probably the current behavior is incorrect or could be enhanced at least.

I think that direction that was taken here #35 is the wrong one (I don't want to offend anybody, it's just my opinion that could be wrong too).

So in HMR there are 2 ways to apply an update to the process:

  1. file system polling — webpack checks for new hot-update files on disk and triggers HMR
  2. signal — something (run-script-webpack-plugin in our case) sends kill to process using SIGUSR2 (default one, could be configured) signal

After #35 we don't send kill anymore, which makes it impossible to use signal HMR API. Probably the correct way to solve it was filtering SIGUSR2 signal:

import { ShutdownSignal } from '@nestjs/common'

app.enableShutdownHooks(
  Object.values(ShutdownSignal).filter((signal) => signal !== 'SIGUSR2')
)

For fs polling current behaviour is works without any problem (I did not check it by myself but looking at code it should).


But I think the behaviour before this change wasn't the best either. Normally, we just kill and start process after emit which works fine. But in HMR we never actually kill process — just sending signal. The problem here is that HMR can corrupt or broke app state so the best way to recover from this — restart the app. But currently it's not possible without full restart of bundler build.

What I suggest: rs (restart via keyboard) should work the same way for normal and hmr setups — just restart the app completely.

Also, I want to mention that restartable, which hides rs feature not documented at all.


Summary:

  1. HMR flow should be different for fs polling and signal API
  2. rs should always restart the app completely

Currently, I patched this plugin to support signal API and rs but not fs polling:

import { ChildProcess, fork } from 'child_process';
import type { Compilation, Compiler, WebpackPluginInstance } from 'webpack';

export type RunScriptRspackPluginOptions = {
  cwd?: string;
  env?: NodeJS.ProcessEnv;
  args: string[];
  nodeArgs: string[];
  name?: string;
  hmr?: boolean;
  keyboardRestart?: boolean;
};

export class RunScriptRspackPlugin implements WebpackPluginInstance {
  private readonly options: RunScriptRspackPluginOptions;

  private childProcess?: ChildProcess;

  private entrypoint?: string;

  constructor(options: Partial<RunScriptRspackPluginOptions> = {}) {
    this.options = {
      ...options,
      args: options.args || [],
      nodeArgs: options.nodeArgs || process.execArgv,
    };

    this.enableKeyboardRestart();
  }

  private enableKeyboardRestart(): void {
    if (this.options.keyboardRestart) {
      process.stdin.setEncoding('utf8');
      process.stdin.on('data', (data: string) => {
        if (data.trim() === 'rs') {
          this.stopProcess();
          this.startProcess();
        }
      });
    }
  }

  private afterEmit = (compilation: Compilation): void => {
    this.setEntrpointPath(compilation);

    if (
      this.childProcess &&
      this.childProcess.connected &&
      this.childProcess?.pid
    ) {
      if (this.options.hmr) {
        this.sendHmrSignal();
      } else {
        this.stopProcess();
        this.startProcess();
      }
    } else {
      this.startProcess();
    }
  };

  apply = (compiler: Compiler): void => {
    compiler.hooks.afterEmit.tap({ name: 'RunScriptPlugin' }, this.afterEmit);
  };

  private setEntrpointPath(compilation: Compilation) {
    const { assets, compiler } = compilation;
    const { options } = this;
    let name;
    const names = Object.keys(assets);
    if (options.name) {
      name = options.name;
      if (!assets[name]) {
        // eslint-disable-next-line no-console
        console.error(
          `Entry ${name} not found. Try one of: ${names.join(' ')}`,
        );
      }
    } else {
      [name] = names;
      if (names.length > 1) {
        // eslint-disable-next-line no-console
        console.log(
          `More than one entry built, selected ${name}. All names: ${names.join(
            ' ',
          )}`,
        );
      }
    }
    if (!compiler.options.output || !compiler.options.output.path) {
      throw new Error('output.path should be defined in webpack config!');
    }

    this.entrypoint = `${compiler.options.output.path}/${name}`;
  }

  private startProcess(): void {
    if (!this.entrypoint)
      throw new Error('run-script-webpack-plugin requires an entrypoint.');

    const { args, nodeArgs, cwd, env } = this.options;
    this.childProcess = fork(this.entrypoint, args, {
      execArgv: nodeArgs,
      stdio: 'inherit',
      cwd,
      env,
    });
  }

  private stopProcess() {
    if (this.childProcess?.pid) {
      // eslint-disable-next-line no-console
      console.log(
        `Sending SIGTERM signal to process with ${this.childProcess.pid} pid`,
      );

      try {
        process.kill(this.childProcess.pid, 'SIGTERM');
      } catch (error) {
        // eslint-disable-next-line no-console
        console.error(
          `Failed to stop process with pid ${this.childProcess.pid}:`,
          error,
        );
      }
    }
  }

  private sendHmrSignal() {
    if (this.childProcess?.pid) {
      // eslint-disable-next-line no-console
      console.log(
        `Sending SIGUSR2 signal to process with ${this.childProcess.pid} pid`,
      );
      process.kill(this.childProcess.pid, 'SIGUSR2');
    }
  }
}

cc @sergiupris

Related to #21

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions