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/); } });