Skip to content

Walk to an ancestor's constructor spec through JDK bytecode delegation - #433

Merged
CatarinaGamboa merged 6 commits into
mainfrom
fix/367-bytecode-delegation
Oct 29, 2026
Merged

CatarinaGamboa merged 6 commits into
mainfrom
fix/367-bytecode-delegation

Conversation

@CatarinaGamboa

@CatarinaGamboa CatarinaGamboa commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #367.

Problem

A JDK exception created directly gets no state from the Throwable spec, so a correct initCause is reported:

RuntimeException e = new RuntimeException("failed");
e.initCause(t);   // State Refinement Error: found true but expected noThrowable(e)

new IllegalStateException("failed") gives the same error. The real bug in this pattern, in scijava-common's CommandInfo (fixed in a6fd9cf), was reported, but only because the state was unknown. The actual cause is that ClassNotFoundException() already sets a (null) cause:

final ClassNotFoundException cnfe = new ClassNotFoundException();
cnfe.initCause(exc);   // IllegalStateException: Can't overwrite cause

Cause

A constructor with no spec of its own gets the default state. #354 lets a lookup walk up to an ancestor's spec, but only:

  • through a hard-coded list (Exception, RuntimeException, Error);
  • for super(...) in user constructors;
  • when the ancestor constructor has the same parameter types.

It also looked only at the first statement. So super(m); initCause(c); in a RuntimeException subclass inherited Throwable(String)'s state, and a second initCause was missed.

Fix

  • ConstructorDelegation reads a JDK class with ASM. It accepts a constructor only when its whole body has this shape:
    aload_0
    (load of a parameter | constant [checkcast after null])*
    invokespecial <direct superclass>.<init>
    return
    
    Any other instruction rejects the constructor: a field write, a method call, this(...), or a branch. For each superclass parameter it records what is passed: one of the constructor's own parameters (any order or subset), or a constant. Int constants passed for a boolean, int, short or byte parameter keep their value. Other constants (null, strings, long/float/double, chars) have no known value, in bytecode and in source alike. Results are cached per constructor.
  • AuxStateHandler replaces DELEGATING_JDK_CLASSES with a walk through delegating constructors:
    • The arguments are mapped at each step.
    • Each ancestor's spec is looked up by exact parameter types, so getFunction's fallback to another overload with the same number of parameters is not used.
    • The spec's parameters are replaced by the values passed: a variable's current instance, or a literal for boolean and int constants. A later assignment to the variable does not change the state. If a to state mentions a parameter whose value is not known (for example, a parameter that has no instance yet), the walk is refused.
    • A spec whose to mentions old(this) is skipped. That happens when a constructor spec leaves another state set unchanged. Calling such a constructor directly already fails on main with Ghost 'old' could not be found; that pre-existing bug is left for a separate issue.
    • State sets of the constructed class that the ancestor's to does not mention keep their default value. For example, Stack() runs Vector(), and Stack's own state set still starts empty.
  • Where it applies: to new X(...) when X has no spec of its own (except anonymous classes), and to a user constructor whose body is only super(...), with no instance or field initializers. That is the source-level version of the bytecode rule. Anything else gets the default state, as before.

ClassNotFoundException() is aload_0; aconst_null; checkcast Throwable; invokespecial ReflectiveOperationException(Throwable), so it reaches Throwable(Throwable) and the object is withThrowable.

Not covered:

  • Only JDK classes are read, through the platform class loader. The verifier runs without the program's classpath, and its own libraries may be different versions from the ones the program uses. Third-party intermediate classes stop the walk.
  • The result depends on the JDK the verifier runs on, which may not be the one the program targets. If that JDK changes a constructor body, or its class files are newer than ASM 9.11 can read, the walk stops without a warning and the object gets the default state, as on main.
  • Not walked: this(...) chains inside a binary class, and constructors that do anything besides delegate.
  • Parameter refinements: a walked spec's parameter refinements are not checked at the new site. Only its state is applied, and nothing was checked there before.
  • State-change hint: on the delegated path the hint metadata that names the call which set the state is not recorded, so state hints cannot point at that call.
  • Overridden methods: methods a subclass overrides and an ancestor's constructor calls (e.g. fillInStackTrace) are not considered, as for any spec. So a subclass of a newly walked class such as IOException, whose override sets the cause, is now missed. Main reported it only because the state was unknown. A call to initCause inside the override is still reported.
  • Type variables: the ancestor lookup compares exact type names, so a spec parameter written as a type variable does not match the erased bytecode type. The walk then stops and the state falls back to the default.

Tests

  • classes/throwable_jdk_subclass_correct: new RuntimeException(msg), new IllegalStateException(msg) and new Exception() followed by initCause, and an IOException subclass calling super(msg). Main reports 3 false errors.
  • classes/throwable_jdk_subclass_error: new ClassNotFoundException(), new RuntimeException(msg, cause) and a ClassNotFoundException subclass calling super(String), each followed by initCause. It also has MissingResourceException, which is not delegating and so gets the default state. TestDelegatedConstructorStates checks that the state found on the first three is withThrowable; main reports the same lines with an unknown state.
  • classes/throwable_subclass_more_than_super_error: super(m); initCause(c);, an implicit super() followed by initCause, and an instance initializer that sets the cause. Main misses the first and third.
  • classes/delegated_constant_arguments_error: LinkedHashSet() reaches HashSet(16, .75f, true), and 16 and true reach the spec as literals.
  • classes/delegated_subclass_sets_error: Stack keeps its own default state after Vector().
  • classes/delegated_argument_reassigned_error: a variable passed to new and assigned before or after the call, and a lambda parameter.
  • classes/delegated_spec_keeping_other_sets_correct: a delegated call to a constructor spec that mentions old(this) gets the default state, without an error.
  • classes/super_swapped_arguments_correct / _error: Sub(boolean y, boolean x) { super(y, x); } for Base(boolean x, boolean y), whose state mentions both parameters. They are replaced at once; replacing one after the other turned both into x (also wrong on main).
  • ConstructorDelegationTest: unit tests for the bytecode matcher.
  • mvn test: 532 tests, 0 failures (rebased on main at 92963a7).

Real-file results

  • scijava-common CommandInfo.java (d9dce68) with the Throwable spec:
    • Main: found !#fresh but expected noThrowable(cnfe).
    • With this PR: found !#fresh && withThrowable(cnfe) but expected noThrowable(cnfe) at line 304.
    • The fixed version (throw new ClassNotFoundException(null, exc)) passes on both.
  • The 53 OSS exercises (74 files, buggy and fixed), on main (4a89df6) and with this PR: identical results.

Dependency

org.ow2.asm:asm 9.11, licence BSD-3-Clause, a single jar with no transitive dependencies. Spoon 11.5.0 does not bring ASM in.

🤖 Generated with Claude Code

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

[Codex review] I checked the bytecode matcher’s accepted instruction sequence, exact overload lookup after delegation, argument mapping, and refusal of constructors with other work. The focused constructor tests and full verifier suite pass locally at this head (./mvnw -q -pl liquidjava-verifier -am test). I found no additional issue beyond the limitations already described in the PR (especially runtime-JDK dependence and unchecked ancestor parameter refinements). Those limits should remain visible to users of this feature.

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

[Claude] Thanks for checking. The two limits (the walk depends on the runtime JDK's bytecode, and ancestor parameter refinements are not checked) stay listed in the PR description and will go into the release notes when this ships.

CatarinaGamboa and others added 6 commits October 29, 2026 10:45
Add ConstructorDelegation, which reads a JDK class with ASM and reports
whether a constructor's whole body is a call to a superclass constructor
(aload_0, loads of its parameters or constants, invokespecial, return),
and what it passes for each superclass parameter. Results are cached
per constructor. Adds org.ow2.asm:asm 9.11 (BSD-3-Clause).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the hard-coded DELEGATING_JDK_CLASSES list: a constructor without
a spec is followed through JDK constructors that only delegate, mapping
each spec parameter back to the argument or constant passed for it, and
looking up each ancestor's spec by exact parameter types. The walk is
used both for super(...) in user constructors and for a direct
new XException(...): new RuntimeException("m") now gets
Throwable(String)'s state, and new ClassNotFoundException() gets
Throwable(Throwable)'s withThrowable state. Anything else falls back to
the default state as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Inherit a super constructor's state only when the constructor body is
  just that super(...) call and the class has no instance initializers or
  field initializers: anything after super(...) may change the state
  (e.g. super(m); initCause(c);), which was missed, also before this
  branch, for subclasses of RuntimeException.
- Pass constant arguments to the spec as literals of the refinement
  language instead of variables named after their value.
- Keep the default value of the constructed class's own state sets that
  the ancestor's spec does not mention (e.g. Stack's, after Vector()).
- Pass a method parameter given to new by its name when it has no
  instance yet, as for other calls.
- Leave anonymous classes (new C() { ... }) to the default state.
- ConstructorDelegation: say that results depend on the JDK the verifier
  runs on; record cache key; check for a missing superclass once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test suite only checks where errors are reported, and main reports
the same lines of throwable_jdk_subclass_error with an unknown state.
Assert that the state found is withThrowable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A variable passed to new is referred to only through its current
  instance. Its raw name made the state follow a later assignment
  (new PrinterStateReasons(n); n = 5;), and a lambda parameter reached
  Z3 with the wrong sort; with no instance the value is unknown and the
  walk stops.
- Skip a spec whose to-state mentions old(this) (it leaves another
  state set unchanged): a new object has no old state, and the delegated
  call failed with "Ghost 'old' could not be found".
- Literals: booleans and ints only; extract the shared CtLiteral case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With Sub(boolean y, boolean x) { super(y, x); } for Base(boolean x, boolean y),
replacing x by y and then y by x turned both into x, so a state that mentions
both parameters was wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CatarinaGamboa
CatarinaGamboa force-pushed the fix/367-bytecode-delegation branch from 636dc4d to 4c8da01 Compare October 29, 2026 10:49
@CatarinaGamboa
CatarinaGamboa merged commit f7f366c into main Oct 29, 2026
1 check 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.

Detect delegating superclass constructors from bytecode instead of a hard-coded list

1 participant