Test local variable table for parameter with signature

Parameters of types with generic signatures cannot be encoded in the
header of the debug_info_item, therefor the parameter is set to null
and the event stream will have a start local extended event active at
pc 0 that declares the parameter as active.

From DEX VM 7 and onward, the encoding results in the VM reporting an
empty range, i.e., [0:0[, for the parameter as well as the valid range
of the parameter with its signature. Some debugger environments do not
deal correctly with this unexpected empty range.

The current output of D8 is to emit null in the parameter list and
issue the START_LOCAL_EXTENDED event after the +0+0 DEFAULT event.

This test checks that the result is the same regardless of the order
of the START_LOCAL_EXTENDED event and the DEFAULT event.

It is not possible for the compiler to emit any other encoding of
parameters for types with generic signatures without resulting in
incorrect/incomplete behavior when stepping. Ensuring a non-empty
range for the initial parameter would cause the method entry to no
longer have the generic signature information.

Bug: b/297843934
Change-Id: I03633d43a330d79c5e5ee3ebe4086374d42ed0be
diff --git a/src/main/java/com/android/tools/r8/graph/DexDebugEventBuilder.java b/src/main/java/com/android/tools/r8/graph/DexDebugEventBuilder.java
index 200e8b4..74b1589 100644
--- a/src/main/java/com/android/tools/r8/graph/DexDebugEventBuilder.java
+++ b/src/main/java/com/android/tools/r8/graph/DexDebugEventBuilder.java
@@ -159,6 +159,9 @@
     for (Entry<DebugLocalInfo> entry : locals.int2ReferenceEntrySet()) {
       if (entry.getValue().signature == null) {
         emittedLocals.put(entry.getIntKey(), entry.getValue());
+      } else if (options.testing.emitDebugLocalStartBeforeDefaultEvent) {
+        events.add(new StartLocal(entry.getIntKey(), entry.getValue()));
+        emittedLocals.put(entry.getIntKey(), entry.getValue());
       }
     }
     lastKnownLocals = new Int2ReferenceOpenHashMap<>(emittedLocals);
diff --git a/src/main/java/com/android/tools/r8/utils/InternalOptions.java b/src/main/java/com/android/tools/r8/utils/InternalOptions.java
index 4fca58a..961117a 100644
--- a/src/main/java/com/android/tools/r8/utils/InternalOptions.java
+++ b/src/main/java/com/android/tools/r8/utils/InternalOptions.java
@@ -2426,6 +2426,8 @@
 
     public boolean disableShortenLiveRanges = false;
 
+    public boolean emitDebugLocalStartBeforeDefaultEvent = false;
+
     // Option for testing outlining with interface array arguments, see b/132420510.
     public boolean allowOutlinerInterfaceArrayArguments = false;
 
diff --git a/src/test/java/com/android/tools/r8/debuginfo/LocalVariableTableForParameterWithSignatureTest.java b/src/test/java/com/android/tools/r8/debuginfo/LocalVariableTableForParameterWithSignatureTest.java
new file mode 100644
index 0000000..ecbe2e4
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/debuginfo/LocalVariableTableForParameterWithSignatureTest.java
@@ -0,0 +1,148 @@
+// Copyright (c) 2023, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+package com.android.tools.r8.debuginfo;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeTrue;
+
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.ToolHelper.DexVm.Version;
+import com.android.tools.r8.debug.DebugTestBase;
+import com.android.tools.r8.debug.DebugTestConfig;
+import com.android.tools.r8.graph.DexDebugEvent.Default;
+import com.android.tools.r8.graph.DexDebugEvent.StartLocal;
+import com.android.tools.r8.graph.DexDebugInfo.EventBasedDebugInfo;
+import com.android.tools.r8.references.Reference;
+import com.android.tools.r8.utils.BooleanUtils;
+import com.android.tools.r8.utils.codeinspector.CodeInspector;
+import com.android.tools.r8.utils.codeinspector.MethodSubject;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.harmony.jpda.tests.framework.jdwp.Frame.Variable;
+import org.apache.harmony.jpda.tests.framework.jdwp.VmMirror;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+@RunWith(Parameterized.class)
+public class LocalVariableTableForParameterWithSignatureTest extends DebugTestBase {
+
+  private final TestParameters parameters;
+  private final boolean startBeforeDefault;
+
+  @Parameterized.Parameters(name = "{0}, startBeforeDefault:{1}")
+  public static List<Object[]> data() {
+    return buildParameters(
+        getTestParameters()
+            .withDefaultCfRuntime()
+            // VMs 4.0 and 4.4 hang on exit so skip testing on those. Their locals are the same as
+            // VMs 5.1 and 6.0.
+            .withDexRuntimesStartingFromIncluding(Version.V5_1_1)
+            .withMinimumApiLevel()
+            .build(),
+        BooleanUtils.values());
+  }
+
+  public LocalVariableTableForParameterWithSignatureTest(
+      TestParameters parameters, boolean startBeforeDefault) {
+    this.parameters = parameters;
+    this.startBeforeDefault = startBeforeDefault;
+  }
+
+  @Test
+  public void test() throws Exception {
+    // Only run one configuration in CF.
+    assumeTrue(parameters.isDexRuntime() || startBeforeDefault);
+    testForD8(parameters.getBackend())
+        .addProgramClasses(TestClass.class)
+        .applyIf(
+            parameters.isDexRuntime(),
+            b ->
+                b.setMinApi(parameters)
+                    .addOptionsModification(
+                        o -> o.testing.emitDebugLocalStartBeforeDefaultEvent = startBeforeDefault))
+        .compile()
+        .inspect(this::checkDebugInfo)
+        .apply(b -> runDebugger(b.debugConfig(parameters.getRuntime())));
+  }
+
+  private void checkDebugInfo(CodeInspector inspector) throws NoSuchMethodException {
+    if (parameters.isCfRuntime()) {
+      return;
+    }
+    MethodSubject method =
+        inspector.method(TestClass.class.getMethod("fun", int.class, List.class));
+    EventBasedDebugInfo debugInfo =
+        method.getMethod().getCode().asDexCode().getDebugInfo().asEventBasedInfo();
+    assertEquals("value", debugInfo.parameters[0].toString());
+    assertNull(debugInfo.parameters[1]);
+    assertEquals(2, debugInfo.parameters.length);
+    Default defaultEvent = (Default) debugInfo.events[startBeforeDefault ? 1 : 0];
+    StartLocal startEvent = (StartLocal) debugInfo.events[startBeforeDefault ? 0 : 1];
+    assertEquals(inspector.getFactory().zeroChangeDefaultEvent, defaultEvent);
+  }
+
+  private void runDebugger(DebugTestConfig debugConfig) throws Throwable {
+    runDebugTest(
+        debugConfig,
+        TestClass.class,
+        breakpoint(Reference.methodFromMethod(TestClass.class.getMethod("main", String[].class))),
+        run(),
+        inspect(
+            inspector -> {
+              VmMirror mirror = inspector.getMirror();
+              long classID = mirror.getClassID(inspector.getClassSignature());
+              long methodID = mirror.getMethodID(classID, "fun");
+              List<Variable> variableTable = mirror.getVariableTable(classID, methodID);
+              boolean hasEmptyRange = false;
+              boolean hasParamValue = false;
+              boolean hasParamStringsWithSignature = false;
+              for (Variable variable : variableTable) {
+                if (variable.getLength() == 0) {
+                  hasEmptyRange = true;
+                } else if ("value".equals(variable.getName())) {
+                  hasParamValue = true;
+                } else if ("strings".equals(variable.getName())) {
+                  assertEquals(
+                      "Ljava/util/List<Ljava/lang/String;>;", variable.getGenericSignature());
+                  hasParamStringsWithSignature = true;
+                } else {
+                  fail("Unexpected variable");
+                }
+              }
+              assertTrue(hasParamValue);
+              assertTrue(hasParamStringsWithSignature);
+              if (parameters.isCfRuntime()
+                  || parameters.isDexRuntimeVersionOlderThanOrEqual(Version.V6_0_1)) {
+                // CF runtimes and the old DEX runtimes report the correct local variable table.
+                // The variable table should be just the two parameters.
+                assertEquals(2, variableTable.size());
+                assertFalse(hasEmptyRange);
+              } else {
+                // Newer ART runtimes report a variable with an empty range. That variable is the
+                // parameter without a signature, e.g., List, and is ended immediately as a variable
+                // is started that also includes the signature, e.g., List<String>.
+                assertEquals(variableTable.toString(), 3, variableTable.size());
+                assertTrue(hasEmptyRange);
+              }
+            }),
+        run());
+  }
+
+  static class TestClass {
+
+    public static void fun(int value, List<String> strings) {
+      System.out.println("" + value + strings);
+    }
+
+    public static void main(String[] args) {
+      fun(42, Arrays.asList(args));
+    }
+  }
+}