Skip to content

[2.x] fix: Classfile fallback for the remaining ClassToAPI reflection sites#1711

Open
rkophs wants to merge 3 commits into
sbt:developfrom
rkophs:classfile-fallback
Open

[2.x] fix: Classfile fallback for the remaining ClassToAPI reflection sites#1711
rkophs wants to merge 3 commits into
sbt:developfrom
rkophs:classfile-fallback

Conversation

@rkophs

@rkophs rkophs commented Jun 4, 2026

Copy link
Copy Markdown

Set as a DRAFT first to see if this is the direction the community wants to go.

Summary

Extends #1660 to the remaining reflection call sites in ClassToAPI. Continues chipping away at sbt/sbt#117 (the oldest open sbt bug, July 2011) where ClassToAPI's reflection on a class triggers NoClassDefFoundError / TypeNotPresentException when a referenced type lives in an optional dep that isn't on the analysis classpath.

#1660 fixed the inner-class case by reading the InnerClasses attribute directly. This PR applies the same pattern to the residual sites — methods, constructors, fields, annotations, and exceptions — by keeping reflection as the primary path and falling back to classfile parsing when reflection throws LinkageError | TypeNotPresentException | ClassNotFoundException. The reflection path is unchanged, so the happy path has zero risk of behavioral regression.

Why now

Our org is migrating onto Mill + Zinc; #1660 cleared the inner-class crashes but we still hit ClassToAPI aborts on classes whose method return / parameter types reference optional deps (e.g. Spring's compileOnly + JBoss VFS pattern), and on methods declaring checked exceptions whose types aren't on the classpath.

Approach

structure(c, enclPkg, cmap) becomes a dispatcher:

try structureReflective(c, enclPkg, cmap)
catch {
  case e: (LinkageError | TypeNotPresentException | ClassNotFoundException) =>
    cmap.log.warn(s"Reflection failed introspecting \${c.getName} (...); falling back to classfile parser")
    structureFromClassfile(c, enclPkg, cmap)
}

The same wrap pattern is applied at c.getAnnotations and c.getTypeParameters in toDefinitions0. The fallback emits a warn so this rare path is observable in incremental compilation logs.

Changes

File What
ClassFile.scala New ParsedAnnotation / ParsedAnnotationArg case classes. Trait methods annotations(attrs), parameterAnnotations(attrs), methodExceptions(attrs). AttributeInfo predicates for Exceptions / RuntimeVisible+Invisible ParameterAnnotations. FieldOrMethodInfo predicates (isPrivate/isProtected/isFinal/isAbstract/isVarArgs/isBridge/isSynthetic/isConstructor/isStaticInit/isMain) and the corresponding ACC_* constants.
Parser.scala Structured parsers for the three new attribute kinds (JVMS §4.7.5, §4.7.16, §4.7.18) reusing the existing constant-pool helpers. element_value serializer covers all 13 kinds including nested annotations and arrays.
ClassToAPI.scala structure() dispatcher + structureFromClassfile. classAnnotationsSafe and classTypeParametersSafe wrappers at the class level. cf* helpers convert FieldOrMethodInfo to api.Def/Val/Var directly. Inherited members carry the parent's (Class, ClassFile) so attribute byte indices resolve against the correct constant pool (a bug caught mid-refactor — without this, reads either return garbage or trip the entry.tag == ConstantUTF8 assertion in toUTF8). Main-class detection moves to the classfile unconditionally (strictly more reliable; no transitive-dep resolution).
DescriptorParser.scala (new) Parses JVM field/method descriptors into xsbti.api.Type without loading the referenced classes. Mirrors ClassToAPI.PrimitiveRefs (Iscala.Int, etc.) so fallback-produced types hash-compatibly with the reflection path.
ParserSpecification.scala +5 tests: methodExceptions against Thread.sleep, FieldOrMethodInfo predicates against JDK, full element_value kind coverage via inline-compiled fixture, parameter annotations indexed by position, bridge/synthetic on erasure bridge.
ClassToAPISpecification.scala +7 tests: fallback on missing return / parameter / field / class-annotation types (last pins a documented limitation), inherited-member parent-constant-pool regression, @throws annotation emission, warn-on-fallback logging.

Deferred (TODOs in source)

These are intentional follow-ups to keep this PR scoped:

  1. Signature attribute parser (JVMS §4.7.9)classTypeParametersSafe currently degrades to an empty type-parameter list when reflection on c.getTypeParameters throws. A signature parser would also let the method/field fallbacks recover generic return types, generic parameter types, and generic field types. TODO on classTypeParametersSafe.
  2. Two-layer supertype recoveryallSuperTypesSafe returns Seq.empty on reflection failure, which drops the cmap.inherited edges (incremental invalidation cascade) and the parent types in the instance Structure (API hash). Right fix: retry with raw getSuperclass/getInterfaces (no generics), then if that still throws, use cf.superClassName + cf.interfaceNames directly. TODO on allSuperTypesSafe.
  3. Silently-dropped class annotations — modern JDKs' c.getAnnotations() silently drops unresolvable annotation types rather than throwing, so classAnnotationsSafe doesn't get a chance to fall back for that specific case. Pinned as a (limitation) test. Fix: switch class annotation reading off reflection entirely. TODO on classAnnotationsSafe.

rkophs and others added 2 commits June 3, 2026 19:34
When a class references a transitive dependency that is missing from the
analysis classpath (e.g. an optional/provided dep), the JVM throws
NoClassDefFoundError / TypeNotPresentException / ClassNotFoundException
the moment ClassToAPI calls c.getMethods, c.getGenericReturnType,
c.getAnnotations, or c.getTypeParameters. This aborts API extraction for
the whole class and breaks incremental compilation downstream.

Keep reflection as the primary path (zero behavior change on the happy
path) and fall back to a classfile-based extractor when reflection
throws. The fallback parses:

  - Field and method descriptors -> erased api.Type (DescriptorParser)
  - RuntimeVisible/Invisible Annotations and ParameterAnnotations
    (JVMS 4.7.16 / 4.7.18) -> api.Annotation + per-parameter annotations
  - Exceptions attribute (JVMS 4.7.5) -> @throws annotations

Each fallback trigger logs a warning. Inherited members carry their
parent's (Class, ClassFile) so attribute byte indices resolve against
the correct constant pool. Main-class detection moves to the classfile
unconditionally (strictly more reliable; no transitive-dep resolution).

Generic Signature parsing (JVMS 4.7.9) is deferred; the fallback uses
erased descriptor types and an empty type-parameter list when reflection
on a generic method/class fails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ParserSpecification: cover the new ClassFile members directly
  - methodExceptions against Thread.sleep (JDK Exceptions attribute)
  - FieldOrMethodInfo predicates against JDK (constructor, varargs, static
    final field)
  - all element_value kinds in RuntimeVisibleAnnotations via an
    inline-compiled fixture (string, int, char, double, float, long, byte,
    short, enum, class, array, nested annotation)
  - parameter annotations indexed by position, including an unannotated
    middle slot
  - bridge/synthetic detection on the erasure bridge of a generic class

ClassToAPISpecification: exercise the reflection-fallback path end-to-end
by compiling against a Missing dep, then loading via a classloader that
excludes it
  - fallback on missing method return type
  - fallback on missing method parameter type
  - fallback on missing field type
  - regression: inherited member annotations read against the parent's
    constant pool, not the child's (the bug caught during the refactor —
    without this, AttributeInfo byte indices either return garbage or
    trip the entry.tag == ConstantUTF8 assertion in toUTF8)
  - @throws annotations emitted on classfile-fallback methods
  - warn-on-fallback observable via a recording Logger
  - documented limitation: missing-type class annotations are silently
    dropped by modern JDKs' getAnnotations(), so the fallback never
    triggers in that specific case; pinned as a "(limitation)" test

ClassToAPI: TODOs documenting the deferred work
  - allSuperTypesSafe: two-layer recovery (raw getSuperclass/getInterfaces
    retry, then classfile superClassName/interfaceNames) — current empty
    return drops inheritance edges from cmap.inherited and parent types
    from the Structure
  - classTypeParametersSafe: parse the Signature attribute (JVMS 4.7.9)
    to recover type parameters — currently degrades to empty
  - classAnnotationsSafe: switch class annotation reading off reflection
    entirely so the silently-dropped case is recoverable

Also restores the pre-existing "TODO: over time, ClassToAPI should switch
the majority of access to the classfile parser" comment on
classFileForClass that the earlier rewrite removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rkophs rkophs marked this pull request as draft June 4, 2026 02:27
@rkophs

rkophs commented Jun 4, 2026

Copy link
Copy Markdown
Author

CLA check for rkophs failed
Please sign the Scala CLA to contribute.
Go to https://contribute.akka.io/contribute/cla/scala to sign the Scala CLA
Error: Process completed with exit code 1.

✅ Signed

@rkophs rkophs marked this pull request as ready for review June 4, 2026 17:52
@eed3si9n

eed3si9n commented Jun 7, 2026

Copy link
Copy Markdown
Member

Thanks for the contribution!

Our org is migrating onto Mill + Zinc; #1660 cleared the inner-class crashes but we still hit ClassToAPI aborts on classes whose method return / parameter types reference optional deps (e.g. Spring's compileOnly + JBoss VFS pattern), and on methods declaring checked exceptions whose types aren't on the classpath.

With this change, were you able to get the build going?

Comment on lines +200 to +203
cmap.log.warn(
s"Reflection failed introspecting ${c.getName} " +
s"(${e.getClass.getSimpleName}: ${e.getMessage}); falling back to classfile parser"
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think we should show warning, if the build user can't take actions to correct it.

Comment on lines -175 to +176
if (
c.getMethods.exists(meth =>
meth.getName == "main" &&
Modifier.isStatic(meth.getModifiers) &&
meth.getParameterTypes.length == 1 &&
meth.getParameterTypes.head == classOf[Array[String]] &&
meth.getReturnType == java.lang.Void.TYPE
)
) {
if (classFileForClass(c).methods.exists(_.isMain)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does this introduce classFileForClass(c) for all classes, which I think is a happy path?

Comment on lines +353 to +356
cmap.log.warn(
s"Reflection failed reading annotations on ${c.getName} " +
s"(${e.getClass.getSimpleName}: ${e.getMessage}); falling back to classfile parser"
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same here about warning that user might not take actions.

@eed3si9n eed3si9n left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I feel like this PR combines a few changes. Would it possible to chunk it into cohesive pieces, both for ease of reviewing, but also so we can revert if we find issues later down the line?

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