From 10bf22429ddead97e6c3c6eb7e13567ebd8dde5d Mon Sep 17 00:00:00 2001 From: Basit Chonka Date: Fri, 18 Sep 2026 15:36:01 +0200 Subject: [PATCH 1/4] terminate worker if execution exceeds 2mins --- packages/shell-bson-parser/.eslintrc.cjs | 2 +- packages/shell-bson-parser/src/index.spec.ts | 43 ++++++++++++++++++- .../shell-bson-parser/src/worker-client.ts | 28 +++++++++++- .../test/fixtures/slow-worker.mjs | 9 ++++ packages/shell-bson-parser/tsconfig-lint.json | 2 +- 5 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 packages/shell-bson-parser/test/fixtures/slow-worker.mjs diff --git a/packages/shell-bson-parser/.eslintrc.cjs b/packages/shell-bson-parser/.eslintrc.cjs index 4b264cafc..5ead6c7f1 100644 --- a/packages/shell-bson-parser/.eslintrc.cjs +++ b/packages/shell-bson-parser/.eslintrc.cjs @@ -5,5 +5,5 @@ module.exports = { tsconfigRootDir: __dirname, project: ['./tsconfig-lint.json'], }, - ignorePatterns: ['webpack.worker.config.cjs'], + ignorePatterns: ['webpack.worker.config.cjs', 'test/fixtures/**'], }; diff --git a/packages/shell-bson-parser/src/index.spec.ts b/packages/shell-bson-parser/src/index.spec.ts index 3a95da2c1..cd215cb04 100644 --- a/packages/shell-bson-parser/src/index.spec.ts +++ b/packages/shell-bson-parser/src/index.spec.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from 'url'; import * as WebWorkerModule from 'web-worker'; import * as api from './index.js'; -import { terminateWorker } from './worker-client.js'; +import { terminateWorker, callWorker } from './worker-client.js'; import { restrictGlobalScope, restrictObjectPrototype, @@ -27,7 +27,11 @@ describe('shell-bson-parser with webworker processing', function () { }); after(function () { - process.env.TEST_WORKER_SCRIPT_URL = initialWorkerScriptUrl; + if (initialWorkerScriptUrl) { + process.env.TEST_WORKER_SCRIPT_URL = initialWorkerScriptUrl; + } else { + delete process.env.TEST_WORKER_SCRIPT_URL; + } terminateWorker(); }); @@ -192,4 +196,39 @@ describe('shell-bson-parser with webworker processing', function () { // It should not modify the default object proto expect(Object.prototype).to.have.property('__proto__'); }); + + describe('execution timeout', function () { + const initialWorkerScriptUrl = process.env.TEST_WORKER_SCRIPT_URL; + + beforeEach(function () { + terminateWorker(); + process.env.TEST_WORKER_SCRIPT_URL = '../test/fixtures/slow-worker.mjs'; + }); + + afterEach(function () { + terminateWorker(); + if (initialWorkerScriptUrl) { + process.env.TEST_WORKER_SCRIPT_URL = initialWorkerScriptUrl; + } else { + delete process.env.TEST_WORKER_SCRIPT_URL + } + }); + + it('rejects a request whose worker thread is wedged past the timeout', async function () { + try { + await callWorker([1000]); + expect.fail('Expected callWorker to throw an error due to timeout'); + } catch (err) { + expect((err as Error)?.message).to.equal( + 'Worker execution timed out after 500ms', + ); + } + }); + + it('spins up a fresh worker for the next call after a timeout kill', async function () { + await callWorker([1000]).catch(() => {}); // timeouts out + const result = await callWorker([0]); + expect(result).to.equal('done'); + }); + }); }); diff --git a/packages/shell-bson-parser/src/worker-client.ts b/packages/shell-bson-parser/src/worker-client.ts index d6961b488..726a07c9f 100644 --- a/packages/shell-bson-parser/src/worker-client.ts +++ b/packages/shell-bson-parser/src/worker-client.ts @@ -6,6 +6,14 @@ import type { WorkerResponse } from './worker-types.js'; /** Close the worker after being idle for 30sec */ const IDLE_TIMEOUT_MS = 30_000; +/** Default execution timeout for worker requests */ +const DEFAULT_EXECUTION_TIMEOUT_MS = 120_000; + +function getExecutionTimeoutMs(): number { + return process.env.TEST_EXECUTION_TIMEOUT_MS + ? Number(process.env.TEST_EXECUTION_TIMEOUT_MS) + : DEFAULT_EXECUTION_TIMEOUT_MS; +} let worker: Worker | null = null; let idleTimer: ReturnType | null = null; @@ -13,7 +21,11 @@ let blobUrl: string | null = null; let nextId = 0; const pending = new Map< number, - { resolve: (v: any) => void; reject: (e: Error) => void } + { + resolve: (v: any) => void; + reject: (e: Error) => void; + executionTimer: ReturnType; + } >(); function scheduleIdleTermination() { @@ -72,6 +84,7 @@ async function createWorker(): Promise { if (!entry) { return; } + clearTimeout(entry.executionTimer); pending.delete(response.id); if (!response.ok) { entry.reject(new Error(response.error)); @@ -97,8 +110,16 @@ async function createWorker(): Promise { export async function callWorker(args: unknown[]): Promise { const activeWorker = await createWorker(); const id = nextId++; + const executionTimeoutMs = getExecutionTimeoutMs(); const promise = new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }); + const executionTimer = setTimeout(() => { + // Terminate the worker is this message is taking too long to execute, + // this means all the other pending requests will also be terminated. + terminateWorker( + new Error(`Worker execution timed out after ${executionTimeoutMs}ms`), + ); + }, executionTimeoutMs); + pending.set(id, { resolve, reject, executionTimer }); }); try { activeWorker.postMessage({ @@ -106,6 +127,8 @@ export async function callWorker(args: unknown[]): Promise { args: markBSON(args), }); } catch (err) { + const entry = pending.get(id); + if (entry) clearTimeout(entry.executionTimer); pending.get(id)?.reject(err as Error); pending.delete(id); } finally { @@ -126,6 +149,7 @@ export function terminateWorker( blobUrl = null; for (const [id, entry] of pending) { + clearTimeout(entry.executionTimer); entry.reject(reason); pending.delete(id); } diff --git a/packages/shell-bson-parser/test/fixtures/slow-worker.mjs b/packages/shell-bson-parser/test/fixtures/slow-worker.mjs new file mode 100644 index 000000000..c955c1f9c --- /dev/null +++ b/packages/shell-bson-parser/test/fixtures/slow-worker.mjs @@ -0,0 +1,9 @@ +self.onmessage = (event) => { + const { id, args } = event.data; + const [delayMs] = args; + const start = Date.now(); + while (Date.now() - start < delayMs) { + // noop + } + self.postMessage({ id, ok: true, result: 'done' }); +}; diff --git a/packages/shell-bson-parser/tsconfig-lint.json b/packages/shell-bson-parser/tsconfig-lint.json index 6bdef84f3..5b09165f8 100644 --- a/packages/shell-bson-parser/tsconfig-lint.json +++ b/packages/shell-bson-parser/tsconfig-lint.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", "include": ["**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "test/fixtures"] } From fd8a197eb475ab3961208558204045af7075d252 Mon Sep 17 00:00:00 2001 From: Basit Chonka Date: Wed, 23 Sep 2026 13:01:02 +0200 Subject: [PATCH 2/4] fix tests --- .../test/fixtures/slow-worker.mjs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/shell-bson-parser/test/fixtures/slow-worker.mjs b/packages/shell-bson-parser/test/fixtures/slow-worker.mjs index c955c1f9c..fc78c4569 100644 --- a/packages/shell-bson-parser/test/fixtures/slow-worker.mjs +++ b/packages/shell-bson-parser/test/fixtures/slow-worker.mjs @@ -1,9 +1,21 @@ +// Avoiding the import of these two functions, +// adding them here to keep this file self-contained +function unmarkBSON(value) { + return value.data; +} +function markBSON(value) { + return { data: value, bsonTypes: new Map() }; +} self.onmessage = (event) => { const { id, args } = event.data; - const [delayMs] = args; + const [delayMs] = unmarkBSON(args); const start = Date.now(); while (Date.now() - start < delayMs) { // noop } - self.postMessage({ id, ok: true, result: 'done' }); + self.postMessage({ + id, + ok: true, + result: markBSON('done'), + }); }; From 5191ca4e88303f74a53de4fa116732da67a70d85 Mon Sep 17 00:00:00 2001 From: Basit Chonka Date: Wed, 23 Sep 2026 13:18:57 +0200 Subject: [PATCH 3/4] accept optional timeout --- packages/shell-bson-parser/src/index.ts | 13 +++++++++-- .../shell-bson-parser/src/worker-client.ts | 23 ++++++++++++++----- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/shell-bson-parser/src/index.ts b/packages/shell-bson-parser/src/index.ts index f639f256b..3d8e4214e 100644 --- a/packages/shell-bson-parser/src/index.ts +++ b/packages/shell-bson-parser/src/index.ts @@ -1,12 +1,21 @@ import type { parse as parseSync } from './parse.js'; import { toJSString } from './stringify.js'; import { ParseMode } from './options.js'; +import type { Options } from './options.js'; import { callWorker, terminateWorker } from './worker-client.js'; +import type { ExecutionOptions } from './worker-client.js'; export const parse = ( - ...args: Parameters -): Promise> => callWorker(args); + input: string, + { + executionTimeoutMs, + ...parseOptions + }: Partial = {}, +): Promise> => { + return callWorker([input, parseOptions], { executionTimeoutMs }); +}; export { ParseMode, toJSString, terminateWorker }; +export type { ExecutionOptions, Options as ParseOptions }; export default parse; diff --git a/packages/shell-bson-parser/src/worker-client.ts b/packages/shell-bson-parser/src/worker-client.ts index 726a07c9f..ee98dac20 100644 --- a/packages/shell-bson-parser/src/worker-client.ts +++ b/packages/shell-bson-parser/src/worker-client.ts @@ -9,12 +9,18 @@ const IDLE_TIMEOUT_MS = 30_000; /** Default execution timeout for worker requests */ const DEFAULT_EXECUTION_TIMEOUT_MS = 120_000; -function getExecutionTimeoutMs(): number { - return process.env.TEST_EXECUTION_TIMEOUT_MS - ? Number(process.env.TEST_EXECUTION_TIMEOUT_MS) - : DEFAULT_EXECUTION_TIMEOUT_MS; +function getExecutionTimeoutMs(initialExecutionMs?: number): number { + if (process.env.TEST_EXECUTION_TIMEOUT_MS) { + return Number(process.env.TEST_EXECUTION_TIMEOUT_MS); + } + return initialExecutionMs ?? DEFAULT_EXECUTION_TIMEOUT_MS; } +export type ExecutionOptions = { + /** Defaults to `120_000` (2 minutes). */ + executionTimeoutMs?: number; +}; + let worker: Worker | null = null; let idleTimer: ReturnType | null = null; let blobUrl: string | null = null; @@ -107,10 +113,15 @@ async function createWorker(): Promise { return worker; } -export async function callWorker(args: unknown[]): Promise { +export async function callWorker( + args: unknown[], + executionOptions?: ExecutionOptions, +): Promise { const activeWorker = await createWorker(); const id = nextId++; - const executionTimeoutMs = getExecutionTimeoutMs(); + const executionTimeoutMs = getExecutionTimeoutMs( + executionOptions?.executionTimeoutMs, + ); const promise = new Promise((resolve, reject) => { const executionTimer = setTimeout(() => { // Terminate the worker is this message is taking too long to execute, From b96a2f9a15e5ac5db6ab409916dcd8cd90f2e521 Mon Sep 17 00:00:00 2001 From: Basit Chonka Date: Wed, 23 Sep 2026 16:47:00 +0200 Subject: [PATCH 4/4] clean up --- packages/shell-bson-parser/src/index.spec.ts | 4 ++-- packages/shell-bson-parser/src/worker-client.ts | 12 ++---------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/shell-bson-parser/src/index.spec.ts b/packages/shell-bson-parser/src/index.spec.ts index cd215cb04..62b58622b 100644 --- a/packages/shell-bson-parser/src/index.spec.ts +++ b/packages/shell-bson-parser/src/index.spec.ts @@ -210,13 +210,13 @@ describe('shell-bson-parser with webworker processing', function () { if (initialWorkerScriptUrl) { process.env.TEST_WORKER_SCRIPT_URL = initialWorkerScriptUrl; } else { - delete process.env.TEST_WORKER_SCRIPT_URL + delete process.env.TEST_WORKER_SCRIPT_URL; } }); it('rejects a request whose worker thread is wedged past the timeout', async function () { try { - await callWorker([1000]); + await callWorker([1000], { executionTimeoutMs: 500 }); expect.fail('Expected callWorker to throw an error due to timeout'); } catch (err) { expect((err as Error)?.message).to.equal( diff --git a/packages/shell-bson-parser/src/worker-client.ts b/packages/shell-bson-parser/src/worker-client.ts index ee98dac20..2c31af030 100644 --- a/packages/shell-bson-parser/src/worker-client.ts +++ b/packages/shell-bson-parser/src/worker-client.ts @@ -9,13 +9,6 @@ const IDLE_TIMEOUT_MS = 30_000; /** Default execution timeout for worker requests */ const DEFAULT_EXECUTION_TIMEOUT_MS = 120_000; -function getExecutionTimeoutMs(initialExecutionMs?: number): number { - if (process.env.TEST_EXECUTION_TIMEOUT_MS) { - return Number(process.env.TEST_EXECUTION_TIMEOUT_MS); - } - return initialExecutionMs ?? DEFAULT_EXECUTION_TIMEOUT_MS; -} - export type ExecutionOptions = { /** Defaults to `120_000` (2 minutes). */ executionTimeoutMs?: number; @@ -119,9 +112,8 @@ export async function callWorker( ): Promise { const activeWorker = await createWorker(); const id = nextId++; - const executionTimeoutMs = getExecutionTimeoutMs( - executionOptions?.executionTimeoutMs, - ); + const executionTimeoutMs = + executionOptions?.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS; const promise = new Promise((resolve, reject) => { const executionTimer = setTimeout(() => { // Terminate the worker is this message is taking too long to execute,