Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/shell-bson-parser/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ module.exports = {
tsconfigRootDir: __dirname,
project: ['./tsconfig-lint.json'],
},
ignorePatterns: ['webpack.worker.config.cjs'],
ignorePatterns: ['webpack.worker.config.cjs', 'test/fixtures/**'],
};
37 changes: 36 additions & 1 deletion packages/shell-bson-parser/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -206,4 +206,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], { executionTimeoutMs: 500 });
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(() => {}); // times out
const result = await callWorker([0]);
expect(result).to.equal('done');
});
});
});
13 changes: 11 additions & 2 deletions packages/shell-bson-parser/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof parseSync>
): Promise<ReturnType<typeof parseSync>> => callWorker(args);
input: string,
{
executionTimeoutMs,
...parseOptions
}: Partial<Options & ExecutionOptions> = {},
): Promise<ReturnType<typeof parseSync>> => {
return callWorker([input, parseOptions], { executionTimeoutMs });
};

export { ParseMode, toJSString, terminateWorker };
export type { ExecutionOptions, Options as ParseOptions };

export default parse;
119 changes: 80 additions & 39 deletions packages/shell-bson-parser/src/worker-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,25 @@ const WebWorker = (WebWorkerModule as unknown as { default: typeof Worker })
import { trackBSON, untrackBSON } from './structured-clone-bson.js';
import type { WorkerResponse } from './worker-types.js';

/** Default execution timeout for worker requests */
const DEFAULT_EXECUTION_TIMEOUT_MS = 120_000;

@Anemy Anemy Sep 21, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should consumers be able to pass this timeout? Will 0 indicate no timeout?

@mabaasit mabaasit Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good idea, will implement this.


export type ExecutionOptions = {
/** Defaults to `120_000` (2 minutes). */
executionTimeoutMs?: number;
};

let worker: Worker | null = null;
let workerPromise: Promise<Worker> | null = null;
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<typeof setTimeout>;
}
>();

const isNodeEnv =
Expand Down Expand Up @@ -42,59 +55,85 @@ async function getWorkerScriptUrl(): Promise<string> {
return blobUrl;
}

async function createWorker(): Promise<Worker> {
function createWorker(): Promise<Worker> {
if (worker) {
return worker;
return Promise.resolve(worker);
}
if (workerPromise) {
return workerPromise;
}

workerPromise = (async () => {
const scriptUrl = await getWorkerScriptUrl();
const newWorker = new WebWorker(scriptUrl, { type: 'module' });
const onMessageHandler = (event: MessageEvent<WorkerResponse>) => {
const response = event.data;
const entry = pending.get(response.id);
if (!entry) {
return;
}
clearTimeout(entry.executionTimer);
pending.delete(response.id);
if (!response.ok) {
entry.reject(response.error);
return;
}
try {
entry.resolve(untrackBSON(response.result));
} catch (err) {
entry.reject(err as Error);
}
};

const onErrorHandler = (event: ErrorEvent) => {
terminateWorker(new Error(event.message || 'Worker error'));
};

const onMessageErrorHandler = () => {
terminateWorker(new Error('Worker message could not be deserialized'));
};

newWorker.addEventListener('message', onMessageHandler);
newWorker.addEventListener('error', onErrorHandler);
newWorker.addEventListener('messageerror', onMessageErrorHandler);

worker = newWorker;
return newWorker;
})();

workerPromise.catch(() => {
workerPromise = null;
});

const scriptUrl = await getWorkerScriptUrl();
worker = new WebWorker(scriptUrl, { type: 'module' });

const onMessageHandler = (event: MessageEvent<WorkerResponse>) => {
const response = event.data;
const entry = pending.get(response.id);
if (!entry) {
return;
}
pending.delete(response.id);
if (!response.ok) {
entry.reject(response.error);
return;
}
try {
entry.resolve(untrackBSON(response.result));
} catch (err) {
entry.reject(err as Error);
}
};

const onErrorHandler = (event: ErrorEvent) => {
terminateWorker(new Error(event.message || 'Worker error'));
};

const onMessageErrorHandler = () => {
terminateWorker(new Error('Worker message could not be deserialized'));
};

worker.addEventListener('message', onMessageHandler);
worker.addEventListener('error', onErrorHandler);
worker.addEventListener('messageerror', onMessageErrorHandler);

return worker;
return workerPromise;
}

export async function callWorker<T>(args: unknown[]): Promise<T> {
export async function callWorker<T>(
args: unknown[],
executionOptions?: ExecutionOptions,
): Promise<T> {
const activeWorker = await createWorker();
const id = nextId++;
const executionTimeoutMs =
executionOptions?.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS;
const promise = new Promise<T>((resolve, reject) => {
pending.set(id, { resolve, reject });
const executionTimer = setTimeout(() => {
// Terminate the worker if 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);
Comment on lines +123 to +126
pending.set(id, { resolve, reject, executionTimer });
});
try {
activeWorker.postMessage({
id,
args: trackBSON(args),
});
} catch (err) {
const entry = pending.get(id);
if (entry) clearTimeout(entry.executionTimer);
pending.get(id)?.reject(err as Error);
pending.delete(id);
}
Expand All @@ -108,9 +147,11 @@ export function terminateWorker(
if (blobUrl) URL.revokeObjectURL(blobUrl);

worker = null;
workerPromise = null;
blobUrl = null;

for (const [id, entry] of pending) {
clearTimeout(entry.executionTimer);
entry.reject(reason);
pending.delete(id);
}
Expand Down
21 changes: 21 additions & 0 deletions packages/shell-bson-parser/test/fixtures/slow-worker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +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] = unmarkBSON(args);
const start = Date.now();
while (Date.now() - start < delayMs) {
// noop
}
self.postMessage({
id,
ok: true,
result: markBSON('done'),
});
};
2 changes: 1 addition & 1 deletion packages/shell-bson-parser/tsconfig-lint.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "test/fixtures"]
}
Loading