Electron Worker Thread
How to write a simple electron worker thread
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.jswill 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.