leibnitz27 / cfr

This is the public repository for the CFR Java decompiler
https://www.benf.org/other/cfr
MIT License
1.96k stars 250 forks source link

Incorrect decompilation related to certain usages of conditionals in catch blocks #357

Closed JoshRosen closed 2 weeks ago

JoshRosen commented 3 weeks ago

CFR version

CFR 0.153-SNAPSHOT (d6f6758)

Compiler

javac 11.0.19

Description

I'm writing to report a bug related to incorrect decompilation of certain conditionals in catch blocks.

I first encountered this problem when decompiling Scala code. Here's a Scala reproducer (Scala 2.12):

class Foo {
  var opt: Option[String] = None
  def bar(): Boolean = {
    opt.foreach { x =>
      if (x == "a") {
        return false
      }
    }
    true
  }
}

uses Scala nonlocal returns (which are implemented by throwing and catching NonLocalReturnControl exceptions).

Taking a look at javap -v output for the bar method:

  public boolean bar();
    descriptor: ()Z
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #4                  // class java/lang/Object
         3: dup
         4: invokespecial #34                 // Method java/lang/Object."<init>":()V
         7: astore_1
         8: aload_0
         9: invokevirtual #36                 // Method opt:()Lscala/Option;
        12: aload_1
        13: invokedynamic #60,  0             // InvokeDynamic #0:apply:(Ljava/lang/Object;)Lscala/Function1;
        18: invokevirtual #66                 // Method scala/Option.foreach:(Lscala/Function1;)V
        21: iconst_1
        22: goto          46
        25: astore_2
        26: aload_2
        27: invokevirtual #70                 // Method scala/runtime/NonLocalReturnControl.key:()Ljava/lang/Object;
        30: aload_1
        31: if_acmpne     41
        34: aload_2
        35: invokevirtual #73                 // Method scala/runtime/NonLocalReturnControl.value$mcZ$sp:()Z
        38: goto          43
        41: aload_2
        42: athrow
        43: goto          46
        46: ireturn
      Exception table:
         from    to  target type
             8    22    25   Class scala/runtime/NonLocalReturnControl

Here, the catch block is checking whether the caught exception's key matches the expected key for non-local return to this method: if it matches, it returns the exception's captured value as the method's return value, otherwise it rethrows the exception because the control flow exception is returning to some higher caller frame.

CFR decompiles this as

public boolean bar() {
        boolean bl;
        Object object = new Object();
        try {
            this.opt().foreach((Function1 & java.io.Serializable & Serializable)x -> {
                Foo.$anonfun$bar$1(object, x);
                return BoxedUnit.UNIT;
            });
            bl = true;
        } catch (NonLocalReturnControl ex) {
            if (ex.key() == object) {
                bl = ex.value$mcZ$sp();
            }
            throw ex;
        }
        return bl;
    }

which is incorrect because ex is being unconditionally rethrown (it should only be rethrown in the else case).

A potential fix

After some investigation, I believe that this problem relates to ConditionalRewriter.considerAsSimpleIf and may be similar to an already-solved past problem for switch statements. In that method, the following change results in correct decompilation for the test case provided above:

diff --git a/src/org/benf/cfr/reader/bytecode/analysis/opgraph/op3rewriters/ConditionalRewriter.java b/src/org/benf/cfr/reader/bytecode/analysis/opgraph/op3rewriters/ConditionalRewriter.java
index 455fba70..876f4cf1 100644
--- a/src/org/benf/cfr/reader/bytecode/analysis/opgraph/op3rewriters/ConditionalRewriter.java
+++ b/src/org/benf/cfr/reader/bytecode/analysis/opgraph/op3rewriters/ConditionalRewriter.java
@@ -587,7 +587,9 @@ lbl10: // 1 sources:
             if (blocksAtStart.size() == blocksAtEnd.size()+1) {
                 List<BlockIdentifier> change = SetUtil.differenceAtakeBtoList(blocksAtStart, blocksAtEnd);
                 // size == 1 already verified, but...
-                if (change.size() == 1 && change.get(0).getBlockType() == BlockType.CASE) {
+                if (change.size() == 1 && (
+                        change.get(0).getBlockType() == BlockType.CASE ||
+                        change.get(0).getBlockType() == BlockType.CATCHBLOCK)) {
                     if (takenTarget.getStatement() instanceof CaseStatement) {
                         // We need to check if the statement LINEARLY preceeding this is in the block we've left.
                         if (stmtLastBlock.getBlockIdentifiers().contains(change.get(0))) {

I believe that this may be a generalization of the change in https://github.com/leibnitz27/cfr/commit/084260a9088caa7d995cd31f9fed3619a3c0b693 for SwitchTest38.

After this change, the Scala example's catch body is decompiled to

        } catch (NonLocalReturnControl ex) {
            if (ex.key() == object) {
                bl = ex.value$mcZ$sp();
            } else {
                throw ex;
            }
        }

as expected.

This is the first time that I've delved into CFR's internals, so I'm not sure if this is a proper fix. I also haven't tested to see whether any other block types might have similar problems.

I've struggled a bit with test environment setup, so I haven't confirmed whether this change breaks any existing CFR tests.


(Thank you for your work on CFR! I have been a regular user for several years and it has proven to be very helpful for my work with Scala-compiled classes.)

JoshRosen commented 3 weeks ago

My original bug report contained an alleged Java reproduction, but upon re-read I spotted that CFR's Java decompilation was perhaps over-complicated (unnecessary use of labels, I think) but technically correct, so I have updated the above to include only the Scala reproducer. Attached below is the Scala-generated class file.

Foo.class.txt


Edit 2:

Here is a new pure-Java reproducer:

public class TryCatchReturn {
    public static String test() {
      String result = "abc";
      Object obj = new Object();
      try {
        System.out.println("In try block");
      } catch (Exception e) {
        if (e == obj) {
          result = e.toString();
        } else {
          throw e;
        }
      }
      return result;
    }
}

CFR decompiled it to

public class TryCatchReturn {
    public static String test() {
        String string = "abc";
        Object object = new Object();
        try {
            System.out.println("In try block");
        }
        catch (Exception exception) {
            if (exception == object) {
                string = exception.toString();
            }
            throw exception;
        }
        return string;
    }
}

where the else has been lost and the re-throw occur unconditionally, but after my proposed change it decompiles to

public class TryCatchReturn {
    public static String test() {
        String string = "abc";
        Object object = new Object();
        try {
            System.out.println("In try block");
        } catch (Exception exception) {
            if (exception == object) {
                string = exception.toString();
            } else {
                throw exception;
            }
        }
        return string;
    }
}

The pure-Java repro class has somewhat similar javap output to the Scala example:

public static java.lang.String test();
    descriptor: ()Ljava/lang/String;
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=0
         0: ldc           #2                  // String abc
         2: astore_0
         3: new           #3                  // class java/lang/Object
         6: dup
         7: invokespecial #1                  // Method java/lang/Object."<init>":()V
        10: astore_1
        11: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        14: ldc           #5                  // String In try block
        16: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        19: goto          38
        22: astore_2
        23: aload_2
        24: aload_1
        25: if_acmpne     36
        28: aload_2
        29: invokevirtual #8                  // Method java/lang/Exception.toString:()Ljava/lang/String;
        32: astore_0
        33: goto          38
        36: aload_2
        37: athrow
        38: aload_0
        39: areturn
      Exception table:
         from    to  target type
            11    19    22   Class java/lang/Exception
leibnitz27 commented 3 weeks ago

Thanks for the (very) detailed report ;) Will have a look!

leibnitz27 commented 2 weeks ago

Couldn't see a better fix than yours - thanks for digging!! Merged