Skip to content

fix(effect): make middlewareGen next() structured and propagate Effect context - #2319

Merged
dinwwwh merged 1 commit into
mainfrom
claude/middlewaregen-interruption-finalizers-srvyld
Oct 23, 2026
Merged

dinwwwh merged 1 commit into
mainfrom
claude/middlewaregen-interruption-finalizers-srvyld

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 23, 2026

Copy link
Copy Markdown
Member

Problem

next() in middlewareGen wrapped opts.next in Effect.tryPromise without a signal, so Effect abandoned the downstream promise on interruption:

  • When the request aborted, or Effect.timeout / Effect.race interrupted next(), the middleware's finalizers ran while downstream middleware and the handler were still running. call() settled early, and the handler was never told to stop. A plain async middleware with try/finally around await next() doesn't have this problem.
  • Services, spans and log annotations applied around next() (Effect.provideService, Effect.withSpan, Effect.annotateLogs) never reached a downstream handlerGen.

Changes

  • server: middleware next({ signal }) replaces the signal for the rest of the chain (downstream middleware and the handler). It is threaded through executeProcedureInternal and decorateMiddleware(...).use(...), and documented in a new "Middleware Signal" section on the middleware page.
  • effect: next() now runs via Effect.callback. Interrupting it aborts the downstream signal (linked with the middleware's own signal) and waits for the chain to settle before finalizers run, matching try/finally around await next().
  • effect: next() passes the fiber's Effect context downstream as effect/context. An effect/context passed to next() is, by type, the incoming one plus changes, so only the services that differ from the incoming context are layered over the fiber context. That keeps the fiber's spans and log annotations, including effect/wrap spans, while still letting the passed context add or override services.

Before and after, with acquireUseRelease around next() and a request aborted at 10ms while the handler takes 50ms:

before: mw-acquire -> mw-release -> call settles -> handler-done
after:  mw-acquire -> handler-done -> mw-release -> call settles

Interruption is cooperative: downstream code that ignores its signal runs to completion, and the interruption waits for it, as it does with a plain middleware.

Tests

  • server: a signal override reaches downstream middleware and the handler, applies only to its own next call, and is forwarded through .use composition.
  • effect, interruption: abort ordering, both single and nested with a plain middleware in between; a request abort interrupting a downstream handlerGen; Effect.timeout interrupting a downstream handlerGen or aborting a plain handler's signal and waiting for it; a signal passed to next being linked with interruption.
  • effect, context: propagation of services, spans and annotations, including through regular middleware; services passed via effect/context, covering layering, overrides, services whose value is undefined, and nested middleware keeping its own span; effect/wrap span nesting with and without an added service.

pnpm vitest run, the bun and cloudflare suites, pnpm type:check, pnpm lint and pnpm docs:validate all pass.


Generated by Claude Code

…t context

`next()` in `middlewareGen` wrapped `opts.next` in `Effect.tryPromise`
without a signal, so Effect abandoned the downstream promise on
interruption: the middleware's finalizers ran while downstream middleware
and the handler were still running, `call()` settled early, and
`Effect.timeout`/`Effect.race` around `next()` never stopped the handler.
Services, spans and log annotations applied around `next()` also never
reached downstream effects.

- server: middleware `next({ signal })` replaces the signal for the rest of
  the chain (downstream middleware and the handler), including through
  `decorateMiddleware(...).use(...)`. Documented on the middleware page.
- effect: `next()` runs via `Effect.callback`. Interrupting it aborts the
  downstream signal (linked with the middleware's own signal) and waits for
  the chain to settle before finalizers run, like `try/finally` around
  `await next()`.
- effect: `next()` passes the fiber's Effect context downstream as
  `effect/context`. An `effect/context` passed to `next()` is the incoming
  one plus changes, so only the changed services are layered over the fiber
  context, keeping the fiber's spans and log annotations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5AXgMUccu8A2egzJusu8m
@macroscopeapp

macroscopeapp Bot commented Oct 23, 2026

Copy link
Copy Markdown

Approvability

Verdict: Needs Human Approval

Macroscope's review found this PR requires human approval — This PR changes shared middleware runtime semantics: downstream handlers now receive composed signal overrides, Effect interruptions wait for promise settlement, and Effect services/spans/annotations propagate through the chain. The behavior is extensively tested but broad enough in cancellation and context handling to merit human review.

You can add or adjust custom eligibility rules. Learn more.

@pkg-pr-new

pkg-pr-new Bot commented Oct 23, 2026

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@2319

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@2319

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@2319

@orpc/client

npm i https://pkg.pr.new/@orpc/client@2319

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@2319

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@2319

@orpc/experimental-effect

npm i https://pkg.pr.new/@orpc/experimental-effect@2319

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@2319

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@2319

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@2319

@orpc/experimental-lock

npm i https://pkg.pr.new/@orpc/experimental-lock@2319

@orpc/mcp

npm i https://pkg.pr.new/@orpc/mcp@2319

@orpc/msw

npm i https://pkg.pr.new/@orpc/msw@2319

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@2319

@orpc/next

npm i https://pkg.pr.new/@orpc/next@2319

@orpc/node

npm i https://pkg.pr.new/@orpc/node@2319

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@2319

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@2319

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@2319

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@2319

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@2319

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@2319

@orpc/server

npm i https://pkg.pr.new/@orpc/server@2319

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@2319

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@2319

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@2319

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@2319

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@2319

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@2319

commit: 722abec

@codecov

codecov Bot commented Oct 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ Minor suggestions only.

Reviewed changes

  • server — next({ signal }) override: MiddlewareNextOptions gains an optional signal; executeProcedureInternal threads a per-next signal so it reaches downstream middleware and the handler, and decorateMiddleware(...).use(...) forwards overrides through composition.
  • effect — structured next(): middlewareGen's next() now uses Effect.callback, passing a signal combined from nextOptions.signal ?? opts.signal and the fiber interrupt signal, and returning a cleanup that waits for the downstream chain to settle — so interrupting next() aborts downstream and runs the middleware's finalizers only after the chain settles.
  • effect — context propagation: next() passes the middleware fiber's effect/context downstream via layerChangedServices, which layers only services that differ from the incoming context over the fiber context, preserving fiber app-added services, spans and log annotations.
  • tests: interruption ordering (single + nested with a plain middleware), Effect.timeout interrupting next, signal linking, and context propagation (services, spans, annotations, undefined service values, effect/wrap nesting) across packages/effect/src/middleware.test.ts, plus signal-forwarding tests in packages/server.

ℹ️ Effect integration docs don't cover the new next() semantics

The behavior change is user-facing, but the effect integration page only says fibers are interrupted on abort (apps/content/docs/integrations/effect.mdx:243); it never states that yield* next() now runs downstream in the middleware's fiber context. Someone reading the Effect docs would not learn that services provided around next() propagate, nor that interrupting next() aborts and waits for the chain.

Technical details
# Document next() context propagation and interruption in effect.mdx

## Affected sites
- apps/content/docs/integrations/effect.mdx:243-264 — the "Interruption" and "Effectful Middleware" sections describe fiber interruption and `yield* next()` but not the new propagation/interruption semantics.

## Required outcome
- Effect docs should state that `yield* next()` continues the downstream `middlewareGen`/`handlerGen` chain within the middleware's fiber context, so `Effect.provideService`, `Effect.withSpan`, and `Effect.annotateLogs` applied around `next()` reach downstream effects.
- And that interrupting `next()` (request abort, `Effect.timeout`, `Effect.race`) aborts the downstream signal and waits for the chain to settle before finalizers run — the same guarantee as `try/finally` around `await next()` for a plain middleware.

## Suggested approach (optional)
- Add one or two sentences to the existing "Interruption" section and a short note in "Effectful Middleware"; the core `Middleware Signal` section already added to apps/content/docs/middleware.mdx can be linked for the signal override itself.

Pullfrog  | Fix it ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@codspeed

codspeed Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

1 warning

⚠️ 5 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 28 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/middlewaregen-interruption-finalizers-srvyld (722abec) with main (83f2fc1)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@dinwwwh
dinwwwh merged commit eb1fea8 into main Oct 23, 2026
14 checks 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