Skip to content

Fix type inference for capture-polymorphic lambdas#25833

Closed
bracevac wants to merge 6 commits intoscala:mainfrom
dotty-staging:ob/fix-25830
Closed

Fix type inference for capture-polymorphic lambdas#25833
bracevac wants to merge 6 commits intoscala:mainfrom
dotty-staging:ob/fix-25830

Conversation

@bracevac
Copy link
Copy Markdown
Contributor

Fix #25830

Problem. val convert = { [C^] => (xs: List[File^{C}]) => xs.map(_ => ()) } had its capture-set type parameter C silently erased during capture checking, so convert[{x}](files) failed with List[File^{}] instead of List[File^{x}].

Root cause. Three places dropped or rewrote the {C} reference in the val's inferred type:

  1. CleanupRetains in CaptureOps.scala rewrote every inferred @retains[T] to @retains[Nothing].
  2. Setup.mapInferred in Setup.scala stripped @retains entirely and then addVar attached a fresh empty variable.
  3. needsVariable said a const capture set containing a type-param reference still needed a fresh variable, severing the link to C across type-arg substitution.

Fix.

  • CleanupRetains now tracks enclosing LambdaType binders and preserves @retains[TypeParamRef] whose binder is in scope (safe for the pickler). Other retains are still normalized to @retains[Nothing], so unrelated tests like cap-paramlists5.scala and nicolas1.scala keep compiling.
  • Setup.mapInferred.innerApply has a new case that preserves a retains whose elements derive from CapSet, and the TypeLambda case keeps CapSet-derived retains on parameter bounds so [C^]'s CapSet^{any} upper bound survives.
  • Setup.needsVariable refuses to wrap a const set whose elements reference a capture-set type parameter.

How much have you relied on LLM-based tools in this contribution?

No, LLMs rely on ME.

How was the solution tested?

New automated tests (including the issue's reproducer, if applicable)

Fix scala#25830

**Problem.** `val convert = { [C^] => (xs: List[File^{C}]) => xs.map(_ => ()) }` had its capture-set
type parameter `C` silently erased during capture checking, so `convert[{x}](files)` failed with
`List[File^{}]` instead of `List[File^{x}]`.

**Root cause.** Three places dropped or rewrote the `{C}` reference in the val's inferred type:

1. `CleanupRetains` in [CaptureOps.scala](compiler/src/dotty/tools/dotc/cc/CaptureOps.scala) rewrote
   every inferred `@retains[T]` to `@retains[Nothing]`.
2. `Setup.mapInferred` in [Setup.scala](compiler/src/dotty/tools/dotc/cc/Setup.scala) stripped
   `@retains` entirely and then `addVar` attached a fresh empty variable.
3. `needsVariable` said a const capture set containing a type-param reference still needed a fresh
   variable, severing the link to `C` across type-arg substitution.

**Fix.**

- `CleanupRetains` now tracks enclosing `LambdaType` binders and preserves `@retains[TypeParamRef]`
  whose binder is in scope (safe for the pickler). Other retains are still normalized to
  `@retains[Nothing]`, so unrelated tests like `cap-paramlists5.scala` and `nicolas1.scala` keep
  compiling.
- `Setup.mapInferred.innerApply` has a new case that preserves a retains whose elements derive from
  `CapSet`, and the `TypeLambda` case keeps CapSet-derived retains on parameter bounds so `[C^]`'s
  `CapSet^{any}` upper bound survives.
- `Setup.needsVariable` refuses to wrap a const set whose elements reference a capture-set type
  parameter.
@bracevac bracevac requested a review from Linyxus April 17, 2026 16:59
Copy link
Copy Markdown
Contributor

@odersky odersky left a comment

Choose a reason for hiding this comment

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

This looks uncomfortably shaky to me. I think the conclusion is that we don't really know how to handle local capset variables in polymorphic lambdas. Instead of trying half solutions I think it might be better to just refuse to handle them. Can we disallow polymorphic lambdas containing capset variables for now? Would tests break if we did that?

// tpe stay erased — see nicolas1.scala, cap-paramlists5.scala.
case ref: TypeRef if ref.derivesFromCapSet =>
val sym = ref.symbol
sym.isType && (binders.nonEmpty || sym.owner.isAnonymousFunction)
Copy link
Copy Markdown
Contributor

@odersky odersky Apr 17, 2026

Choose a reason for hiding this comment

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

The logic behind this condition is unclear to me. If binders is non-empty, we treat all captset variables as local, not just variables referring to a binder? Why?

case tp @ AnnotatedType(parent, annot: RetainingAnnotation) =>
if Feature.ccEnabled then
if annot.symbol == defn.RetainsCapAnnot then tp
else if annot.retainedType.retainedElementsRaw.exists(isLocalCapSetParam) then
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.

Why not forall? In fact, no matter what we do I fear we will always do the wrong thing for a capset that contains a local binder and also some other references.

// original retains set so the reference to `C` survives later type-arg
// substitution. Without this, `{C}` would be silently erased to `{}`.
// See i25830.
val parent1 = apply(parent) match
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.

I am a bit nervous that we bypass the logic for retained elements containing a capset variable. What about the other elements? What if the variable is not from a local binder? Aren't we overfitting the test cases here?

if bnd.derivesFromCapSet then bnd else bnd.dropAllRetains
tp.derivedLambdaType(
paramInfos = tp.paramInfos.mapConserve(_.dropAllRetains.bounds),
paramInfos = tp.paramInfos.mapConserve(cleanBound(_).bounds),
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.

Same thing here. Why exempt all capset variables?

@odersky odersky assigned bracevac and unassigned odersky Apr 17, 2026
Copy link
Copy Markdown
Contributor

@odersky odersky left a comment

Choose a reason for hiding this comment

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

I think we have to conclude that we don't really know how to handle polymorphic lambdas over capset variables. Instead of trying half solutions, maybe it is better to reject for now such lambdas as an implementation restriction? Would any existing tests break if we did so?

@bracevac
Copy link
Copy Markdown
Contributor Author

I think we have to conclude that we don't really know how to handle polymorphic lambdas over capset variables. Instead of trying half solutions, maybe it is better to reject for now such lambdas as an implementation restriction? Would any existing tests break if we did so?

We do have a couple. Some are purely for checking parsing, like the caps-paramlists* tests. Others are more critical, e.g., pos test i24309-region.scala shows how to do regions and subregions and that uses lower-bounded capture-polymorphic lambdas.

RFC @Linyxus @noti0na1 @natsukagami

@odersky
Copy link
Copy Markdown
Contributor

odersky commented Apr 17, 2026

Hmm. Can we find another criterion that allows the current tests (in particular regions) and that lets us stay on safe ground?

@bracevac
Copy link
Copy Markdown
Contributor Author

In the regions example we require a polymorphic lambda of type [R^] => Region[R] => T. So completely ruling them out would be a pity. Though note that here, the R does not occur in the capture set of the parameter immediately following [R^].

The problem lies with inference for lambdas that should have types like [R^] => File^{R} => T. If we remove the "questionable" second case in isLocalCapSetParam, then we could still support those due to the desugaring of lambdas (R occurs in the immediate next parameter def anonfun[R](x: File^{R})...). But then in curried lambdas with more than one parameter, subsequent R occurrences will be erased (since they're now TypeRefs):
[R^] => File^{R} => File^{R} => T becomes [R^] => File^{R} => File^{} => T.

@bracevac
Copy link
Copy Markdown
Contributor Author

Superseded by #25853

@bracevac bracevac closed this Apr 20, 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.

CC: Type inference for capture-polymorphic lambdas is broken

2 participants