Skip to content

Wait till process stopped #23

Description

@nick4fake

These features would be really nice to have:

  • Wait till process stopped during restart
  • Kill process with SIGKILL if it didn't stop during timeout

Example:

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

const killProcess = async ({pid, signal = 'SIGTERM', timeout}) => {
  process.kill(pid, signal);
  let count = 0;
  do {
    try {
      process.kill(pid, 0);
    } catch (e) {
      return;
    }
    if ((count += 100) > timeout) {
      break;
    }

    await new Promise(cb => setTimeout(cb, 100));
  } while (true);

  try {
    process.kill(pid, 'SIGKILL');
  } catch (e) {
    return;
  }

  count = 0;
  do {
    try {
      process.kill(pid, 0);
    } catch (e) {
      return;
    }
    if ((count += 100) > timeout) {
      throw new Error('Timeout process kill');
    }

    await new Promise(cb => setTimeout(cb, 100));
  } while (true);

};

export type RunScriptWebpackPluginOptions = {
  autoRestart?: boolean;
  args: string[];
  cwd?: string;
  keyboard: boolean;
  name?: string;
  nodeArgs: string[];
  restartable?: boolean;
  signal: boolean | string;
  killTimeoutMs?: number;
};

function getSignal(signal: string | boolean) {
  // allow users to disable sending a signal by setting to `false`...
  if (signal === false) return;
  if (signal === true) return 'SIGUSR2';
  return signal;
}

export class RunScriptWebpackPlugin implements WebpackPluginInstance {
  private readonly options: RunScriptWebpackPluginOptions;

  private worker?: ChildProcess;

  private _entrypoint?: string;

  constructor(options: Partial<RunScriptWebpackPluginOptions> = {}) {
    this.options = {
      autoRestart: true,
      signal: false,
      killTimeoutMs: 5000,
      // Only listen on keyboard in development, so the server doesn't hang forever
      keyboard: process.env.NODE_ENV === 'development',
      ...options,
      args: [...(options.args || [])],
      nodeArgs: options.nodeArgs || process.execArgv,
    };

    if (this.options.restartable) {
      this._enableRestarting();
    }
  }

  private _enableRestarting(): void {
    if (this.options.keyboard) {
      process.stdin.setEncoding('utf8');
      process.stdin.on('data', (data: string) => {
        if (data.trim() === 'rs') {
          this._restartServer();
        }
      });
    }
  }

  private async _restartServer(): Promise<void> {
    console.log('Restarting app...');
    if (this.worker?.pid) {
      const signal = getSignal(this.options.signal);
      await killProcess({
        pid: this.worker.pid,
        signal,
        timeout: this.options.killTimeoutMs,
      });
    }
    this._startServer((worker) => {
      this.worker = worker;
    });
  }

  private afterEmit = (compilation: Compilation, cb: (err?: any) => void): void => {
    if (this.worker && this.worker.connected && this.worker?.pid) {
      if (this.options.autoRestart) {
        this._restartServer().then(() => cb()).catch(err => cb(err));
        return;
      }
      const signal = getSignal(this.options.signal);
      if (signal) {
        killProcess({
          pid: this.worker.pid,
          signal,
          timeout: this.options.killTimeoutMs,
        }).then(() => cb()).catch(err => cb(err));
      }
      cb();
      return;
    }

    this.startServer(compilation, cb);
  };

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

  private startServer = (compilation: Compilation, cb: () => void): void => {
    const {assets, compiler} = compilation;
    const {options} = this;
    let name;
    const names = Object.keys(assets);
    if (options.name) {
      name = options.name;
      if (!assets[name]) {
        console.error(
          `Entry ${name} not found. Try one of: ${names.join(' ')}`,
        );
      }
    } else {
      name = names[0];
      if (names.length > 1) {
        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}`;
    this._startServer((worker) => {
      this.worker = worker;
      cb();
    });
  };

  private _startServer(cb: (arg0: ChildProcess) => void): void {
    const {args, nodeArgs, cwd} = this.options;
    if (!this._entrypoint) throw new Error('run-script-webpack-plugin requires an entrypoint.');

    const child = fork(this._entrypoint, args, {
      execArgv: nodeArgs,
      stdio: 'inherit',
      cwd,
    });
    setTimeout(() => cb(child), 0);
  }
}

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