Skip to content

fix(shared): await fn() in runWithSpan / runInSpanContext so an already-rejected promise is not observed as unhandled - #1984

Closed
You-keitou wants to merge 1 commit into
middleapi:mainfrom
You-keitou:fix/run-with-span-await
Closed

fix(shared): await fn() in runWithSpan / runInSpanContext so an already-rejected promise is not observed as unhandled#1984
You-keitou wants to merge 1 commit into
middleapi:mainfrom
You-keitou:fix/run-with-span-await

Conversation

@You-keitou

Copy link
Copy Markdown

Summary

runWithSpan and runInSpanContext return fn() directly when no tracer / span is configured:

if (!tracer) {
  return fn()
}

When fn is an async handler that throws before its first await (a very common shape — e.g. an auth guard as the first statement of a procedure handler), fn() returns an already-rejected promise. Returning it from an async function without await resolves the outer promise through a PromiseResolveThenableJob, so the rejection handler is attached a couple of microtasks after the rejection happened.

Node ignores that gap, but workerd (Cloudflare Workers) reports it as an unhandled rejection. Under @cloudflare/vitest-pool-workers every such handler call shows up as Unhandled Rejection in the test summary (and fails the run on vitest 4), even though RPCHandler.handle() correctly turns the error into the error response.

This PR changes both no-tracer paths to return await fn(), which attaches the handler immediately (the same thing the tracer path already does via return await fn(span) inside callback). No behavior change otherwise.

Reproduction

In a @cloudflare/vitest-pool-workers project (vitest 4, pool-workers 0.16 or 0.22):

import { ORPCError, os } from '@orpc/server'
import { RPCHandler } from '@orpc/server/fetch'

it('async handler throwing before its first await', async () => {
  const handler = new RPCHandler({
    ping: os.handler(async () => {
      throw new ORPCError('UNAUTHORIZED')
    }),
  })
  const { response } = await handler.handle(
    new Request('http://localhost/rpc/ping', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ json: {} }),
    }),
    { prefix: '/rpc', context: {} },
  )
  expect(response?.status).toBe(401) // passes …
})
// … but vitest reports: Errors 1 error — Unhandled Rejection: Error: Unauthorized
//   ❯ runWithSpan node_modules/@orpc/shared/dist/index.mjs:127:12

Bisect results in that environment:

handler shape unhandled rejection reported
synchronous throw no
async, throw after an await no
async, throw before the first await yes
same, with this patch applied to @orpc/shared no

We currently carry this as a one-line patch in our repo; happy to add a test if you can suggest a way to assert it outside of workerd (Node's unhandled-rejection tracking does not observe the gap).

…dy-rejected promise is not observed as unhandled
@dinwwwh

dinwwwh commented Sep 5, 2026

Copy link
Copy Markdown
Member

I reproduced this under workerd (the @cloudflare/vitest-pool-workers setup in packages/cloudflare) and looked into why only workerd observes the gap.

Why workerd reports it and Node does not

return fn() from an async function resolves the outer promise with a thenable, so the already-rejected inner promise only gets its handler when V8's PromiseResolveThenableJob microtask runs. Node processes "rejected with no handler" events after the whole microtask queue drains, so by then the handler is attached. workerd's legacy UnhandledRejectionHandler scheduled its check as a microtask at rejection time, which lands in the queue before that thenable job and therefore fires too early. That is cloudflare/workerd#6020.

workerd already fixed this behind a compat flag

workerd now defers the check to V8's microtasks-completed callback (same timing as Node), gated by the unhandled_rejection_after_microtask_checkpoint compatibility flag. It is on by default for compatibility_date >= 2026-03-03; the opposite flag is no_unhandled_rejection_after_microtask_checkpoint. pool-workers 0.16 pulls miniflare 4.20260504, so its workerd is also newer than the flag. Seeing the misfire on both 0.16 and 0.22 points at the project's compatibility_date being older than 2026-03-03.

Results with the reproduction from this PR (plus a bare runWithSpan variant) in packages/cloudflare:

compatibility_date @orpc/shared unhandled rejection errors
2026-07-01 (flag on) main 0
2026-03-02 (flag off) main 2, both "throw before first await"
2026-03-02 (flag off) this PR 0

So bumping compatibility_date (or adding the flag) is a workaround available today. This change still makes sense for projects on older dates, since return await fn() costs nothing.

About a test

packages/cloudflare already runs under workerd, so a regression test can live there: a wrangler config with no_unhandled_rejection_after_microtask_checkpoint (or a date before 2026-03-03) reproduces the misfire deterministically, and vitest surfaces it as an unhandled error.

@You-keitou

Copy link
Copy Markdown
Author

Withdrawing this for now — I want to prepare it more carefully (with a proper test) before asking for a review. Sorry for the noise.

@You-keitou You-keitou closed this Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants