August 5, 2023 · Andrew Hyde

Electron Worker Thread

How to write a simple electron worker thread

electronworker-threadstypescript

We recently started getting reports from one of our customers that they were experiencing a hard to reproduce issue. Only after using the product for a long time would the issue reproduce.

They needed to create a file, upload it, run it, wait for results, a long time-consuming process. Often many runs were successful before the issue was seen. To make things worse the file had to be in Julian time relative to the current time so rerunning the same file over again was not a viable option.

I decided to write an electron app that would generate the file, upload and run it. I wanted the user to be able to cancel the test at any point. There are several ways to do this. I decided to go with a Node Worker Thread which can be terminated

I am not going to delve into the actual tests that were performed. I am going to focus on creating, talking to, and terminating the worker thread. I will provide a sample application that provides the core concepts and walk through it.

Sample Application

The sample application can be found here.

Clone, install dependencies, and run.

git clone git@github.com:achyde/electronWorkerThreadExample.git
cd electronWorkerThreadExample
npm install
npm run dev

Main Electron File

The main electron file at electron/main/index.ts is below:

process.env.DIST_ELECTRON = join(__dirname, '..')
process.env.DIST = join(process.env.DIST_ELECTRON, '../dist')
process.env.PUBLIC = app.isPackaged ? process.env.DIST : join(process.env.DIST_ELECTRON, '../public')

import {app, BrowserWindow, ipcMain} from 'electron'
import {join} from 'path';
import {Worker} from 'worker_threads';

let win: BrowserWindow | null = null
const url = process.env.VITE_DEV_SERVER_URL as string
const indexHtml = join(process.env.DIST, 'index.html')

let testLog = "";

const log_test_status = (s: string) => {
    win.webContents.send("test_status", s);
    testLog += `${s}\n`;
}

const log_julian_time = (s: string) => win.webContents.send("julian_time", s)
const log_past_messages = () => win.webContents.send("get_log", testLog)

let worker = null;

const terminate_worker = async () => {
    if (worker) {
        await worker.terminate();
        worker = null;
    }
};

const stopTest = async () => {
    log_test_status("Stopping test.");
    await terminate_worker();
};

const runTest = (workerData) => {
    return new Promise((resolve, reject) => {

        worker = new Worker(`${__dirname}/testRunner.js`, {workerData});

        worker.on('message', (e) => {
            if (e.msg) {
                log_test_status(e.msg);
            }
            if (e.julian_time) {
                log_julian_time(e.julian_time);
            }
        });
        worker.on('close', () => stopTest().then(() => resolve({done: true})));
        worker.on('error', reject);
        worker.on('exit', (code: number) => {
            if (code !== 0){
                reject(new Error(`Worker stopped with exit code ${code}`));
            }
            else{
                resolve({done: true});
            }
        });
    });
}

const createWindow = async () => {

    win = new BrowserWindow({title: 'Main window', webPreferences: {preload: join(__dirname, '../preload/index.js')}});

    if (app.isPackaged) {
        win.loadFile(indexHtml);
    } else {
        win.loadURL(url);
        win.maximize();
    }

    ipcMain.on("clear_log", () => testLog = "");
    ipcMain.on("get_log",  () => log_past_messages());
    ipcMain.on("stop_test", () => stopTest());
    ipcMain.on("start_test", async (event, args) => await runTest({runs: args.runs, steps: args.steps}).finally(() => log_test_status("Test done.")));
};

app.on("ready", createWindow);

The main application does several things:

  • Services events received from the browser on ipcMain
  • Starts a Worker Thread running a simulated test
  • Services events from testRunner.ts
  • Cancel the worker thread upon request

Service Events Received From Browser On ipcMain

import {app, BrowserWindow, ipcMain} from 'electron'

    ...

    ipcMain.on("clear_log", () => testLog = "");
    ipcMain.on("get_log", () => log_past_messages());
    ipcMain.on("stop_test", stopTest);
    ipcMain.on("start_test", async (event, args) => await runTest({runs: args.runs, steps: args.steps}).finally(() => log_test_status("Test done.")));

Import the ipcMain module and listen for events using the on method. The start_test message illustrates passing data from the browser.

Start Worker Thread

import {Worker} from 'worker_threads';

    ...

        const worker = new Worker(`${__dirname}/testRunner.js`, {workerData});

        worker.on('message', (e) => {
            if (e.msg) {
                log_test_status(e.msg);
            }
            if (e.julian_time) {
                log_julian_time(e.julian_time);
            }
        });
        worker.on('close', () => stopTest(worker).then(() => resolve({done: true})));
        worker.on('error', reject);
        worker.on('exit', (code: number) => {
            if (code !== 0){
                reject(new Error(`Worker stopped with exit code ${code}`));
            }
            else{
                resolve({done: true});
            }
        });

Starting the thread is done with new Worker(\${__dirname}/testRunner.js`, {workerData});` the workerData will be passed to testRunner.js as will be shown later.

Registering for events from the Worker Thread is done with the on method. Here we are listening for e to be an object with two potential key/value pairs [msg | julian_time]

TIP

The Worker Thread reads in a javascript file and executes it. ${__dirname}/testRunner.js will be presented later. It is written in typescript, however at the time the electron app is running this has been transpiled into js. This is why we do not import testRunner.ts.

testRunner.ts Worker Thread Application

This is a typescript application. It is going to be compiled to js before time of execution and renamed to testRunner.js, so import the file testRunner.js, however we edit testRunner.ts in the electron main directory.

This application starts an interval that executes every 250ms and logs the Julian time to the main thread. The program then executes a series of asynchronous calls which could be long-running REST calls to a device under test (DUT), as was the case in our real-world application.

import {parentPort, workerData} from "worker_threads";

const log_test_status = (val: string) => parentPort.postMessage({msg: val});
const log_julian_time = (val: string) => parentPort.postMessage({julian_time: val});

const runTest = async (runs: number, steps: number) => {

    const sleep = (amount: number) => new Promise((resolve) => setTimeout(resolve, amount));

    const timeBetweenStepsSeconds = 10;
    const startDelaySec = 5;
    const secondsToWaitForTest = (timeBetweenStepsSeconds * steps) + startDelaySec;

    const getJulianTime = () => ((new Date().getTime() / 86400000) + 2440587.5).toFixed(5);

    log_test_status(`Start test session`);
    log_test_status(`${getJulianTime()}`);

    setInterval(() => log_julian_time(getJulianTime()), 250);

    for (let i = 0; i < runs; i++) {

        log_test_status(`Running test ${i + 1}`);

        await Promise.resolve(log_test_status("Start of async work"))
            .then(() => log_test_status(`sleep(2000)`))
            .then(() => sleep(2000))
            .then(() => log_test_status(JSON.stringify({success:true})))
            .then(() => log_test_status(`Waiting ${secondsToWaitForTest} seconds for test ${i + 1} to finish`))
            .then(() => sleep(secondsToWaitForTest * 1000))
            .then(() => log_test_status(`Finished test ${i + 1}`))
    }
    log_test_status(`End test session`);
}

runTest(workerData.runs, workerData.steps).then(() => parentPort.close());

parentPort

The parentPort is used for communication between the main and service threads. Issuing a parentPort.postMessage call causes the handler registered with the worker.on("message",...) to be called. Issuing a parentPort.close call causes the handler registered with the worker.on("close",...) to be called.

In the case of this application we are sending messages from the worker thread to the main thread using an object. The main thread is expecting the object can have keys [msg | julian_time]. String values assigned to the msg key are logged as test output to the browser. Strings assigned to the key julian_time will be sent to the browser as a new time stamp. The distinction between them is primarily for display purposes.

workerData

The workerData object is populated with data from the main thread. This is the data object passed to the call new Worker(\${__dirname}/testRunner.js`, {workerData});`.

TIP

workerData must be serializable. For example, you will not be able to pass the logger methods directly down from the main thread to the worker thread.

Service Events From testRunner.ts

The main thread will handle two message types from testRunner msg, and julian_time. Both of these are forwarded to the UI on separate data channels. testRunner can emit an object to the main thread by calling parentPort.message().

worker.on('message', (e) => {
            if (e.msg) {
                log_test_status(e.msg);
            }
            if (e.julian_time) {
                log_julian_time(e.julian_time);
            }
        });

Cancel the Worker Tread Upon Request

The main electron application is responsible for terminating the running Worker Thread. Below are the relevant code blocks in electron/main/index.ts

...

let worker = null;

const terminate_worker = async () => {
    if (worker) {
        await worker.terminate();
        worker = null;
    }
};

const stopTest = async () => {
    log_test_status("Stopping test.");
    await terminate_worker();
};

...

        worker.on('close', () => stopTest().then(() => resolve({done: true})));
...

As you can see the worker.terminate() call is responsible for ending the Worker Thread.

Client Application

The client UI is written in Vue. It is composed primarily of two files electron/preload/index.ts and src/App.vue. Look at the sample application for all files needed to build.

Electron Preload

The preload file is parsed and appended to the window object. In this case, the api.send and api.receive methods are appended to the window.

const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('api', {
  receive: (channel, listener) => {
    ipcRenderer.on(channel, (event, ...args) => listener(...args));
  },
  send: (channel, data) => {
    ipcRenderer.send(channel, data);
  },
})

App.vue

The App.vue file illustrates the sending and receiving of data to the backend electron application.

<template>
  <header class="flex items-center justify-center p-2 sticky top-0 z-50 border rounded bg-green-300">
    <button @click="clearLogs" class="ml-2 border border-black rounded px-2 py-1 bg-orange-400" type="button">
      Clear Logs
    </button>
    <div class="grid grid-cols-2 gap-1">
      <label class="text-right">Num Runs:</label><input type="text" class="text-center" v-model="runs">
      <label class="text-right">Steps / Run:</label><input type="text" class="text-center" v-model="steps">
    </div>
    <button @click="startTest" class="ml-2 border border-black rounded px-2 py-1 bg-orange-400" type="button">
      Start Test
    </button>
    <button @click="stopTest" class="ml-2 border border-black rounded px-2 py-1 bg-orange-400" type="button">
      Stop Test
    </button>
  </header>
  <div class="grid grid-cols-2">
    <section class="flex flex-col">
      <h2>Test Log: Current Julian Time = {{ julianTimeNow }}</h2>
      <div class="flex flex-col">
        <div v-for="status in test_status"> {{ status }}</div>
      </div>
    </section>
  </div>
</template>

<script setup lang="ts">
import {ref, reactive, onMounted} from "vue";

const runs = ref(1);
const steps = ref(1);
const test_status = reactive([] as string[]);
const julianTimeNow = ref("");
const clearLogs = () => {
  window.api.send("clear_log");
  test_status.splice(0, test_status.length);
  julianTimeNow.value = "";
};
const startTest = () => window.api.send("start_test", {runs: runs.value, steps: steps.value});
const stopTest = () => window.api.send("stop_test");
onMounted(() => {
  window.api.receive("test_status", (value: string) => test_status.push(`${value}`));
  window.api.receive("julian_time", (value: string) => julianTimeNow.value = value);
  window.api.receive("get_log", (value: string) => {
    test_status.splice(0, test_status.length);
    test_status.push(...value.split("\n"));
  });

  window.api.send("get_log");
});

</script>

Conclusion

Using the Worker Thread to execute a long-running task is straightforward. This also simplified canceling the task. Hopefully this was useful to you.

← Back to all posts