Repository navigation
fix(effect): make middlewareGen next() structured and propagate Effect context - #2319
Conversation
…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
ApprovabilityVerdict: 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. |
More templates
@orpc/ai-sdk
@orpc/arktype
@orpc/bun
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/experimental-effect
@orpc/evlog
@orpc/hibernation
@orpc/json-schema
@orpc/experimental-lock
@orpc/mcp
@orpc/msw
@orpc/nest
@orpc/next
@orpc/node
@orpc/openapi
@orpc/opentelemetry
@orpc/pinia-colada
@orpc/pino
@orpc/publisher
@orpc/ratelimit
@orpc/server
@orpc/shared
@orpc/swr
@orpc/tanstack-query
@orpc/trpc
@orpc/valibot
@orpc/zod
commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
ℹ️ Minor suggestions only.
Reviewed changes
- server —
next({ signal })override:MiddlewareNextOptionsgains an optionalsignal;executeProcedureInternalthreads a per-nextsignal so it reaches downstream middleware and the handler, anddecorateMiddleware(...).use(...)forwards overrides through composition. - effect — structured
next():middlewareGen'snext()now usesEffect.callback, passing a signal combined fromnextOptions.signal ?? opts.signaland the fiber interrupt signal, and returning a cleanup that waits for the downstream chain to settle — so interruptingnext()aborts downstream and runs the middleware's finalizers only after the chain settles. - effect — context propagation:
next()passes the middleware fiber'seffect/contextdownstream vialayerChangedServices, 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.timeoutinterruptingnext, signal linking, and context propagation (services, spans, annotations,undefinedservice values,effect/wrapnesting) acrosspackages/effect/src/middleware.test.ts, plus signal-forwarding tests inpackages/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.deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
Merging this PR will not alter performance1 warning
Comparing Footnotes
|

Problem
next()inmiddlewareGenwrappedopts.nextinEffect.tryPromisewithout a signal, so Effect abandoned the downstream promise on interruption:Effect.timeout/Effect.raceinterruptednext(), 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 withtry/finallyaroundawait next()doesn't have this problem.next()(Effect.provideService,Effect.withSpan,Effect.annotateLogs) never reached a downstreamhandlerGen.Changes
next({ signal })replaces the signal for the rest of the chain (downstream middleware and the handler). It is threaded throughexecuteProcedureInternalanddecorateMiddleware(...).use(...), and documented in a new "Middleware Signal" section on the middleware page.next()now runs viaEffect.callback. Interrupting it aborts the downstream signal (linked with the middleware's own signal) and waits for the chain to settle before finalizers run, matchingtry/finallyaroundawait next().next()passes the fiber's Effect context downstream aseffect/context. Aneffect/contextpassed tonext()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, includingeffect/wrapspans, while still letting the passed context add or override services.Before and after, with
acquireUseReleasearoundnext()and a request aborted at 10ms while the handler takes 50ms:Interruption is cooperative: downstream code that ignores its
signalruns to completion, and the interruption waits for it, as it does with a plain middleware.Tests
nextcall, and is forwarded through.usecomposition.handlerGen;Effect.timeoutinterrupting a downstreamhandlerGenor aborting a plain handler's signal and waiting for it; a signal passed tonextbeing linked with interruption.effect/context, covering layering, overrides, services whose value isundefined, and nested middleware keeping its own span;effect/wrapspan nesting with and without an added service.pnpm vitest run, the bun and cloudflare suites,pnpm type:check,pnpm lintandpnpm docs:validateall pass.Generated by Claude Code