From 3612d5215c0c596e37b1acbb1ed6df1a44916582 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 15 Apr 2026 10:48:58 -0400 Subject: [PATCH] feat: surface main-thread call site in error stack traces Wrap errors in an async catch block inside trackValue (pool) and runOnDedicated (dedicated), re-throwing with the worker error as cause. V8's async stack traces then extend the new error with the caller's await chain, so users see where run(), exec(), or await task was called. Adds one microtask hop per dispatch. The error subclass identity and cause chain are preserved. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/error-stack-traces.md | 20 ++++++++++ src/dedicated-runner.ts | 14 ++++++- src/worker-pool.ts | 38 ++++++++++++------ test/error.test.ts | 66 ++++++++++++++++++++++++++++---- 4 files changed, 117 insertions(+), 21 deletions(-) create mode 100644 .changeset/error-stack-traces.md diff --git a/.changeset/error-stack-traces.md b/.changeset/error-stack-traces.md new file mode 100644 index 0000000..13c3020 --- /dev/null +++ b/.changeset/error-stack-traces.md @@ -0,0 +1,20 @@ +--- +'moroutine': minor +--- + +Include main-thread call site in error stack traces + +Errors thrown during task dispatch now have stack traces that show where `run()`, `exec()`, or `await task` was called. The original worker-side error is preserved as `err.cause` with its own stack pointing to the moroutine source. + +``` +Error: boom + at trackValue (worker-pool.ts:52:15) + at async loadUser (user-code.ts:7:3) + at async main (user-code.ts:11:3) { + [cause]: Error: boom + at fixtures/math.ts:6:9 // original throw site on the worker + at MessagePort. (worker-entry.ts:173:25) +} +``` + +Built-in error subclass identity (`TypeError`, `RangeError`, etc.) is preserved on the outer wrapper. diff --git a/src/dedicated-runner.ts b/src/dedicated-runner.ts index 3839992..3dbb7ce 100644 --- a/src/dedicated-runner.ts +++ b/src/dedicated-runner.ts @@ -28,10 +28,20 @@ function unref(worker: Worker): void { if (count === 0) worker.unref(); } -export function runOnDedicated(id: string, args: unknown[]): Promise { +export async function runOnDedicated(id: string, args: unknown[]): Promise { const worker = getWorker(id); ref(worker); - return execute(worker, id, args).finally(() => unref(worker)); + try { + return await execute(worker, id, args); + } catch (err) { + if (err instanceof Error) { + const Ctor = err.constructor as ErrorConstructor; + throw new Ctor(err.message, { cause: err }); + } + throw new Error(String(err), { cause: err }); + } finally { + unref(worker); + } } export function runStreamOnDedicated(id: string, args: unknown[]): AsyncIterable { diff --git a/src/worker-pool.ts b/src/worker-pool.ts index d123325..0c58d55 100644 --- a/src/worker-pool.ts +++ b/src/worker-pool.ts @@ -41,16 +41,30 @@ export function workers(sizeOrOpts?: number | WorkerOptions, opts?: WorkerOption const inflight = new Set>(); const activeCounts = new Map(); - function track(handle: WorkerHandle, promise: Promise): Promise { + async function trackValue(handle: WorkerHandle, promise: Promise): Promise { inflight.add(promise); activeCounts.set(handle, (activeCounts.get(handle) ?? 0) + 1); - promise - .finally(() => { - inflight.delete(promise); - activeCounts.set(handle, (activeCounts.get(handle) ?? 1) - 1); - }) - .catch(() => {}); - return promise; + try { + return await promise; + } catch (err) { + if (err instanceof Error) { + const Ctor = err.constructor as ErrorConstructor; + throw new Ctor(err.message, { cause: err }); + } + throw new Error(String(err), { cause: err }); + } finally { + inflight.delete(promise); + activeCounts.set(handle, (activeCounts.get(handle) ?? 1) - 1); + } + } + + function trackStream(handle: WorkerHandle, done: Promise): void { + inflight.add(done); + activeCounts.set(handle, (activeCounts.get(handle) ?? 0) + 1); + done.then(() => { + inflight.delete(done); + activeCounts.set(handle, (activeCounts.get(handle) ?? 1) - 1); + }); } function terminateAll(): void { @@ -89,7 +103,7 @@ export function workers(sizeOrOpts?: number | WorkerOptions, opts?: WorkerOption function dispatch(task: Task): Promise { if (disposed) return Promise.reject(new Error('Worker pool is disposed')); const { worker, handle } = resolveWorkerAndHandle(task); - return track(handle, execute(worker, task.id, task.args)); + return trackValue(handle, execute(worker, task.id, task.args)); } function makeWorkerHandle(worker: Worker, idx: number): WorkerHandle { @@ -99,11 +113,11 @@ export function workers(sizeOrOpts?: number | WorkerOptions, opts?: WorkerOption if (task instanceof AsyncIterableTask) { if (disposed) throw new Error('Worker pool is disposed'); const { iterable, done } = dispatchStream(worker, task.id, task.args, channelOpts); - track(handle, done); + trackStream(handle, done); return iterable; } if (disposed) return Promise.reject(new Error('Worker pool is disposed')); - return track(handle, execute(worker, task.id, task.args)); + return trackValue(handle, execute(worker, task.id, task.args)); }, get thread() { return worker; @@ -123,7 +137,7 @@ export function workers(sizeOrOpts?: number | WorkerOptions, opts?: WorkerOption if (disposed) throw new Error('Worker pool is disposed'); const { worker, handle } = resolveWorkerAndHandle(taskOrTasks); const { iterable, done } = dispatchStream(worker, taskOrTasks.id, taskOrTasks.args, channelOpts); - track(handle, done); + trackStream(handle, done); return iterable; } if (Array.isArray(taskOrTasks)) { diff --git a/test/error.test.ts b/test/error.test.ts index f83203c..a8f9e7e 100644 --- a/test/error.test.ts +++ b/test/error.test.ts @@ -18,14 +18,16 @@ describe('error handling', () => { }); }); - it('preserves stack trace pointing to worker source', async () => { + it('preserves stack trace pointing to worker source via cause', async () => { await using run = workers(1); try { await run(fail('stack check')); assert.fail('should have thrown'); } catch (err) { assert.ok(err instanceof Error); - assert.match(err.stack!, /fixtures\/math\.ts/); + const cause = err.cause as Error; + assert.ok(cause instanceof Error); + assert.match(cause.stack!, /fixtures\/math\.ts/); } }); @@ -36,19 +38,22 @@ describe('error handling', () => { assert.fail('should have thrown'); } catch (err) { assert.ok(err instanceof TypeError); - assert.equal(err.message, 'type check'); + assert.equal((err as Error).message, 'type check'); } }); - it('preserves error cause', async () => { + it('preserves error cause (nested through wrapper)', async () => { await using run = workers(1); try { await run(failCause('with cause')); assert.fail('should have thrown'); } catch (err) { assert.ok(err instanceof Error); - assert.ok(err.cause instanceof RangeError); - assert.equal((err.cause as Error).message, 'root cause'); + // outer wrapper has the worker error as cause; worker error has RangeError as its cause + const workerErr = (err as Error).cause as Error; + assert.ok(workerErr instanceof Error); + assert.ok(workerErr.cause instanceof RangeError); + assert.equal((workerErr.cause as Error).message, 'root cause'); } }); @@ -58,7 +63,54 @@ describe('error handling', () => { assert.fail('should have thrown'); } catch (err) { assert.ok(err instanceof TypeError); - assert.match((err as Error).stack!, /fixtures\/math\.ts/); + const cause = (err as Error).cause as Error; + assert.match(cause.stack!, /fixtures\/math\.ts/); + } + }); + + it('main-thread stack includes run() caller', async () => { + await using run = workers(1); + + async function someCaller() { + await run(fail('trace check')); + } + + try { + await someCaller(); + assert.fail('should have thrown'); + } catch (err) { + assert.ok(err instanceof Error); + assert.match((err as Error).stack!, /someCaller/); + } + }); + + it('main-thread stack includes exec() caller', async () => { + await using run = workers(1); + + async function someCaller() { + await run.workers[0].exec(fail('exec trace check')); + } + + try { + await someCaller(); + assert.fail('should have thrown'); + } catch (err) { + assert.ok(err instanceof Error); + assert.match((err as Error).stack!, /someCaller/); + } + }); + + it('main-thread stack includes await-task caller on dedicated worker', async () => { + async function someCaller() { + await fail('dedicated trace check'); + } + + try { + await someCaller(); + assert.fail('should have thrown'); + } catch (err) { + assert.ok(err instanceof Error); + assert.match((err as Error).stack!, /someCaller/); } }); -- 2.51.2