Skip to content

cellb: a Worker's unhandled rejections are the Worker's, not the Node process's - #15

Merged
mkotelnikov merged 2 commits into
mainfrom
ccr-ab3c0d30-6du8do-worker-rejections
Oct 28, 2026
Merged

mkotelnikov merged 2 commits into
mainfrom
ccr-ab3c0d30-6du8do-worker-rejections

Conversation

@mkotelnikov

Copy link
Copy Markdown
Contributor

Stacked on #14: the base branch is #14's, so the diff shows only this change. Merge #14 first.

Problem

On workerd and celld, a promise the Worker leaves rejected is the Worker's affair: workerd logs it, celld ignores it. On Node, the Worker shares the host's process, so such a rejection reached the process's unhandledRejection listeners.

workerd's vendored Durable Object tests leave four of them (assert.rejects(…) unawaited). vitest counted them as errors, so vitest run in packages/cellb exited 1 with every test passing, and the root pnpm test stopped there.

Change

  • src/core/worker-rejections.ts (new, Node only): every Worker event runs in an AsyncLocalStorage scope naming its runtime. Node calls unhandledRejection listeners in the rejected promise's async context, so a hook on process.emit finds the scope and passes the rejection to the runtime's onUnhandledRejection instead of the process. Rejections outside any Worker reach the process as before. Browsers are unaffected: a Worker runs in its own worker there, and the scope is a no-op.
  • Events in scope:
    • Durable Object events, through the runtime's events root;
    • the stateless fetch;
    • code the host dispatches itself, through the new runtime.runEvent(fn), which cron triggers now use;
    • queue batches, which arrive as Durable Object events.
  • CellRuntimeOptions.onUnhandledRejection: the default writes one line on stderr, [cellb] <script>: unhandled rejection in Worker code: …, outside the Worker's console, so the user logs compared with celld's recordings do not change.
  • Docs: docs/celld-compatibility.md describes the behaviour; the root README's note now explains the stderr lines instead of the exit-1.
  • Also merged in: Examples move to examples/; the root README gets the comparison with celld #14's fake-openai move (packages/ → examples/), on Examples move to examples/; the root README gets the comparison with celld #14's branch.

Tests

  • test/p20-worker-rejections.test.ts: rejections from a fetch (before and after an await), a Durable Object method and runEvent reach onUnhandledRejection only; the host's own rejections still reach the process; the default writes the stderr line and nothing to console.error.

  • p09-celld-conformance now exits 0. Its four rejections are reported under the Worker that raised them (wd-actor-stub), no longer attributed to the test vitest was running.

  • Root pnpm test exits 0:

    Workspace Result
    fake-openai 3 passed
    cellb 358 passed, 6 expected fail
    agents 2 passed
    flue 6 passed
  • Interop: 123 passed, 6 expected fail; exit 0.

  • Chromium: 208 passed, 1 failed, 15 skipped. The failure was tabs.spec.ts › a restarted ServiceWorker gets the shards of every tab back (Failed to fetch around the ServiceWorker restart). It passed 5 out of 5 when rerun on this branch, and this change does nothing in browsers.

  • Flue in the browser: 8 passed.

  • Lint and typecheck are clean in all 4 workspaces.


Generated by Claude Code

… process's

On workerd and celld a promise the Worker leaves rejected is the Worker's affair (workerd logs
it, celld ignores it). On Node the Worker shares the host's process, so such a rejection reached
the process's `unhandledRejection` listeners: vitest counted the four that workerd's vendored
tests leave (`assert.rejects(…)` unawaited) as errors and `vitest run` exited 1 with every test
passing, which stopped the root `pnpm test`.

Each Worker event (Durable Object events through the runtime's events root, the stateless
fetch, and host-dispatched code through the new `runtime.runEvent`, which cron triggers use)
now runs in a scope that names its runtime. Node calls `unhandledRejection` listeners in the
rejected promise's async context, so a hook on `process.emit` finds the scope and hands the
rejection to the runtime's `onUnhandledRejection` instead. The default writes one line on
stderr, outside the Worker's console, so recorded user logs do not change. Rejections outside
any Worker reach the process as before. Browsers are unaffected (a Worker has a worker of its
own there).

test/p20 covers fetch (before and after an await), a Durable Object method, runEvent, the
host's own rejections, and the default report. The root README's note is updated: the root
`pnpm test` now runs every workspace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXJQA3LbggJHi5qZBpUKe8
@mkotelnikov
mkotelnikov changed the base branch from ccr-ab3c0d30-6du8do-examples-dir to main October 28, 2026 07:04
@mkotelnikov
mkotelnikov merged commit 22b5cbe into main Oct 28, 2026
1 check passed
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