fix: guard Handler.@@ with Request <:< In instead of IsRequest (#3141) - #4230
fix: guard Handler.@@ with Request <:< In instead of IsRequest (#3141)#4230DPS0340 wants to merge 2 commits into
Conversation
) Handler.@@ casts the handler's input to Request. On a route with path parameters that input is a tuple such as (String, Request), and the cast fails at runtime: java.lang.ClassCastException: class zio.http.Request cannot be cast to class scala.Tuple2 The guard asked for IsRequest[In1]. IsRequest is contravariant, so IsRequest[Request] also conforms to IsRequest[Nothing]; since In1 is only bounded by In1 <: In, the compiler satisfied the implicit by inferring In1 = Nothing and the tuple was never examined. Constraining In directly leaves nothing to infer around: implicitly[Request <:< Nothing] // Cannot prove that zio.http.Request <:< Nothing. This is the guard change from zio#3150 by @987Nabil, extracted so it applies to current main. IsRequest is deprecated rather than removed, since it is public API. This is source-breaking: an aspect applied to a non-Request handler stops compiling. Such code could never run successfully.
✅ Deploy Preview for zio-http ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
✅ Ready to approve
The change correctly enforces a sound compile-time constraint in the exact locations that performed the unsafe cast, and includes a targeted regression test for the reported failure mode.
Note: this review does not count toward required approvals for merging.
Pull request overview
This PR fixes a soundness hole in Handler.@@ where a handler could be cast to Request input even when its real input is a tuple (e.g. from path parameters), leading to a runtime ClassCastException (Issue #3141). It replaces the ineffective IsRequest[In1] guard with a direct constraint on the handler’s actual input type, so the misuse is rejected at compile time, and keeps IsRequest deprecated for source compatibility.
Changes:
- Replace the
Handler.IsRequest[In1]implicit guard onHandler.@@withRequest <:< In(and update version-specific wrappers accordingly). - Deprecate
Handler.IsRequestwith explanatory scaladoc, retaining it as public API. - Add a regression
typeChecktest ensuring aspects cannot be applied to tuple-input handlers produced by path parameters.
File summaries
| File | Description |
|---|---|
| zio-http/shared/src/main/scala/zio/http/Handler.scala | Updates @@ guards to constrain In via Request <:< In and deprecates IsRequest with rationale. |
| zio-http/shared/src/main/scala-3/zio/http/HandlerVersionSpecific.scala | Aligns Scala 3 context-aspect wrapper guard with the new Request <:< In constraint. |
| zio-http/shared/src/main/scala-2/zio/http/HandlerVersionSpecific.scala | Aligns Scala 2 context-aspect wrapper guard with the new Request <:< In constraint. |
| zio-http/jvm/src/test/scala/zio/http/HandlerAspectSpec.scala | Adds a compile-time regression test covering the path-parameter tuple-input case. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 0
- Review effort level: Low
Note
Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…rence The mdoc snippet used a bare `Handler.identity`, whose `In` inferred to `Nothing`. That only typechecked through the contravariance hole this PR closes, so the build fails on it: error: handler_aspect.md:435: Cannot prove that zio.http.Request <:< Nothing. Replaced with a handler that is explicitly `Request => Response`, which is what the surrounding prose describes. `docs/mdoc` passes.
|
CI caught the judgement call I got wrong in the PR description. I wrote that I'd deliberately left
To be clear about what this does and does not change: the regression is still real and still in the PR body. Bare Current state, all local:
|
|
Reporting against my own PR: this breaks binary compatibility, and my earlier one did not. I ran The implicit parameter is part of the erased signature, so swapping This is a real difference between the two approaches, and it goes against the one I argued for:
So the honest scoreboard is that neither patch is strictly better. Mine was binary-compatible with a bad error message; this one has the right design and the right diagnostic but needs a binary-compatibility exception. What I'm not going to do is add the filter myself. ProblemFilters.exclude[DirectMissingMethodProblem]("zio.http.Handler.@@")But adding an exclusion is a maintainer's call about what breakage is acceptable in a 3.x line — it's the same decision as whether this belongs in 3.x at all, just expressed in a config file. Me quietly adding it to get a green check would be hiding the cost inside the patch that causes it. Three ways forward, your pick:
Everything else is green: |
|
The other red check is not related to this PR either.
So the state of this PR is:
Only the Mima one needs anything from me, and I've laid out the three options above rather than pick one. Separately, |
|
I went looking for a fourth option — a fix that keeps the good error message and stays binary-compatible — so the Mima decision wouldn't be forced on you. There isn't one. Reporting the negative result rather than leaving you to wonder. The idea was to keep 1. 2.
So the erased signature has to change. The two-line Mima filter is unavoidable if you want the better diagnostic. 3. Along the way I also found why def error(...): Handler[Any, Nothing, Any, Response] =
(fromResponse(Response.status(status)) @@ Middleware.interceptHandlerStateful(...))That is sound — a handler accepting Updated scoreboard, with nothing left to invent:
The three options from my earlier comment stand, minus the hope that a fourth existed. I still haven't added the filter — that call is yours. |
|
Closing the loop on CI so the state is unambiguous. Three checks are red; one is mine and two are not, and I verified that rather than asserting it.
Both pre-existing failures reproduce on PRs that change unrelated things — #4225 is a On the Mima failure, I checked whether it was avoidable and it is not. The idea was to keep
So the decision I can't make for you is unchanged, and now provably minimal:
Also worth flagging separately: |
|
This issue is not fixable on 3.x |
Fixes #3141. Supersedes #4229, which I'll close once this is up.
/claim #3141This is @987Nabil's design from #3150, extracted so it applies to current
main. Credit for the fix is his; what I did was verify it still works and keep it small.The bug
Handler.@@casts the handler's input toRequest:On a route with path parameters that input is a tuple —
(String, Request)— so the cast fails at runtime:The guard that should have caught this asked for
IsRequest[In1]. There is correctly no instance for tuples:But
IsRequestis declaredIsRequest[-A], soIsRequest[Request]also conforms toIsRequest[Nothing]— andIn1is only bounded byIn1 <: In. The compiler inferredIn1 = Nothing, resolved the implicit, and never looked at the tuple.The fix
Constraining
In— the handler's actual input — rather thanIn1, which the compiler chooses. There is nothing left to infer around:Four call sites: twice in
Handler.scala, once in eachHandlerVersionSpecific.scala.IsRequestis deprecated rather than removed, since it's public API.Why not #3150 as-is
I tried. It merges onto
mainwith one trivial conflict but doesn't compile — #3150 also removedScopefromHandler.apply, andmainhas evolved that area since. Its diff against today'smainis+2062/-12050across 87 files. This PR is just the guard.Verification
zioHttpJVM/Test/compileHandlerAspectSpecHandlerSpec+RouteSpec+RoutesSpec+MiddlewareSpecscalafmtCheckBehaviour, against a locally published build:
Cannot prove that Request <:< (String, Request)Handler.fromFunction[Request](_ => Response.ok) @@ aspectHandler.identity @@ aspectCannot prove that Request <:< NothingAdded a
typeCheckregression test toHandlerAspectSpecpinning the first row.The cost, stated plainly
This is source-breaking. Any
@@whose handler input isn't staticallyRequeststops compiling. Two categories:Nothinginputs, e.g. bareHandler.identity @@ aspect. This works today, because aNothing-input handler is never invoked with a mismatched value. It will now fail to compile, and the fix is to annotate the handler.Category 2 is a genuine regression, and it appears in your own docs —
handler_aspect.mdhasval myHandler = Handler.identity. I have not touched that file in this PR; if you take this, that snippet needs a type annotation and I'm happy to add it, but I didn't want to bundle a docs change into the decision.@987Nabil — your 2024 note said "this breaking change is our best option 😞", and having now measured it I think that was right. But it is your call whether it belongs in 3.x or waits. If the answer is "still not for 3.x", that's a fine answer and I'll close this.