fix: complete methods for classes loaded by multiple ClassLoaders (#3218)

This commit is contained in:
DragonFSKY
2026-07-19 00:11:00 +08:00
committed by GitHub
parent 0d0066deaa
commit 435e985dd8
2 changed files with 297 additions and 14 deletions
@@ -4,6 +4,7 @@ import com.taobao.arthas.core.shell.session.Session;
import com.taobao.arthas.core.shell.term.Tty;
import com.taobao.arthas.core.util.SearchUtils;
import com.taobao.arthas.core.util.StringUtils;
import com.taobao.arthas.core.util.matcher.Matcher;
import com.taobao.arthas.core.util.usage.StyledUsageFormatter;
import com.taobao.middleware.cli.CLI;
import com.taobao.middleware.cli.Option;
@@ -17,6 +18,7 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
@@ -220,28 +222,51 @@ public class CompletionUtils {
className = tokens.get(tokens.size() - 3).value();
}
Set<Class<?>> results = SearchUtils.searchClassOnly(completion.session().getInstrumentation(), className, 2);
if (results.size() != 1) {
// no class found or multiple class found
Matcher<String> classNameMatcher = SearchUtils.classNameMatcher(className, false);
Set<Class<?>> results = new LinkedHashSet<Class<?>>();
String matchedClassName = null;
for (Class<?> clazz : completion.session().getInstrumentation().getAllLoadedClasses()) {
if (clazz == null || !classNameMatcher.matching(clazz.getName())) {
continue;
}
if (matchedClassName == null) {
matchedClassName = clazz.getName();
} else if (!matchedClassName.equals(clazz.getName())) {
completion.complete(Collections.<String>emptyList());
return true;
}
results.add(clazz);
}
if (results.isEmpty()) {
// no class found
completion.complete(Collections.<String>emptyList());
return true;
}
Class<?> clazz = results.iterator().next();
List<String> res = new ArrayList<String>();
for (Method method : clazz.getDeclaredMethods()) {
if (StringUtils.isBlank(lastToken)) {
res.add(method.getName());
} else if (method.getName().startsWith(lastToken)) {
res.add(method.getName());
Set<String> res = new LinkedHashSet<String>();
for (Class<?> clazz : results) {
Method[] methods;
try {
methods = clazz.getDeclaredMethods();
} catch (LinkageError e) {
// A method signature may refer to a type that is not available to this class loader.
// Ignore that class and still offer methods from matching classes loaded elsewhere.
continue;
}
for (Method method : methods) {
if (StringUtils.isBlank(lastToken)) {
res.add(method.getName());
} else if (method.getName().startsWith(lastToken)) {
res.add(method.getName());
}
}
}
res.add("<init>");
if (StringUtils.isBlank(lastToken) || "<init>".startsWith(lastToken)) {
res.add("<init>");
}
if (res.size() == 1) {
completion.complete(res.get(0).substring(lastToken.length()), true);
completion.complete(res.iterator().next().substring(lastToken.length()), true);
return true;
} else {
CompletionUtils.complete(completion, res);
@@ -0,0 +1,258 @@
package com.taobao.arthas.core.shell.cli;
import com.taobao.arthas.core.shell.cli.impl.CliTokenImpl;
import com.taobao.arthas.core.shell.session.Session;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.Mockito;
import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;
import java.io.File;
import java.lang.instrument.Instrumentation;
import java.lang.reflect.Proxy;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
public class CompletionUtilsTest {
private static final String DUPLICATE_TARGET = "test.arthas.DuplicateTarget";
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void shouldMergeMethodCandidatesFromClassesLoadedByDifferentClassLoaders() throws Exception {
Class<?> first = compileDuplicateTarget("public void alpha() {}\npublic void common() {}");
Class<?> second = compileDuplicateTarget("public void beta() {}\npublic void common() {}");
RecordingCompletion completion = completionFor(methodCompletionTokens(DUPLICATE_TARGET, ""), first, second);
Assert.assertTrue(CompletionUtils.completeMethodName(completion));
Assert.assertNotNull(completion.candidates);
Assert.assertTrue(completion.candidates.contains("alpha"));
Assert.assertTrue(completion.candidates.contains("beta"));
Assert.assertTrue(completion.candidates.contains("common"));
Assert.assertTrue(completion.candidates.contains("<init>"));
Assert.assertEquals(1, Collections.frequency(completion.candidates, "common"));
Assert.assertEquals(new HashSet<String>(completion.candidates).size(), completion.candidates.size());
}
@Test
public void shouldCompletePartialMethodNameAcrossClassesLoadedByDifferentClassLoaders() throws Exception {
Class<?> first = compileDuplicateTarget("public void alpha() {}\npublic void common() {}");
Class<?> second = compileDuplicateTarget("public void beta() {}\npublic void common() {}");
RecordingCompletion completion = completionFor(methodCompletionTokens(DUPLICATE_TARGET, "al"), first, second);
Assert.assertTrue(CompletionUtils.completeMethodName(completion));
Assert.assertEquals("pha", completion.value);
Assert.assertTrue(completion.terminal);
Assert.assertNull(completion.candidates);
}
@Test
public void shouldCompleteConstructorPrefix() throws Exception {
Class<?> first = compileDuplicateTarget("public void alpha() {}");
Class<?> second = compileDuplicateTarget("public void beta() {}");
RecordingCompletion completion = completionFor(methodCompletionTokens(DUPLICATE_TARGET, "<"), first, second);
Assert.assertTrue(CompletionUtils.completeMethodName(completion));
Assert.assertEquals("init>", completion.value);
Assert.assertTrue(completion.terminal);
Assert.assertNull(completion.candidates);
}
@Test
public void shouldCompleteEmptyListWhenNoClassMatches() {
RecordingCompletion completion = completionFor(methodCompletionTokens(DUPLICATE_TARGET, ""));
Assert.assertTrue(CompletionUtils.completeMethodName(completion));
Assert.assertEquals(Collections.<String>emptyList(), completion.candidates);
Assert.assertNull(completion.value);
}
@Test
public void shouldCompleteEmptyListWhenClassPatternMatchesDifferentClassNames() throws Exception {
Class<?> first = compileTargetWithMissingSignature("test.arthas.FirstTarget");
Class<?> second = compileTargetWithMissingSignature("test.arthas.SecondTarget");
assertMethodsUnresolvable(first);
assertMethodsUnresolvable(second);
RecordingCompletion completion = completionFor(methodCompletionTokens("test.arthas.*Target", ""), first, second);
Assert.assertTrue(CompletionUtils.completeMethodName(completion));
Assert.assertEquals(Collections.<String>emptyList(), completion.candidates);
Assert.assertNull(completion.value);
}
@Test
public void shouldSkipClassWithUnresolvableMethodSignature() throws Exception {
Class<?> healthy = compileDuplicateTarget("public void alpha() {}");
Class<?> broken = compileTargetWithMissingSignature(DUPLICATE_TARGET);
assertMethodsUnresolvable(broken);
RecordingCompletion completion = completionFor(methodCompletionTokens(DUPLICATE_TARGET, ""), healthy, broken);
Assert.assertTrue(CompletionUtils.completeMethodName(completion));
Assert.assertTrue(completion.candidates.contains("alpha"));
Assert.assertTrue(completion.candidates.contains("<init>"));
Assert.assertFalse(completion.candidates.contains("broken"));
}
private static void assertMethodsUnresolvable(Class<?> target) {
try {
target.getDeclaredMethods();
Assert.fail("Expected an unresolved method signature for " + target.getName());
} catch (LinkageError expected) {
// expected
}
}
private Class<?> compileDuplicateTarget(String methods) throws Exception {
return compileTarget(DUPLICATE_TARGET, methods);
}
private Class<?> compileTarget(String className, String methods) throws Exception {
return compileTarget(className, methods, null);
}
private Class<?> compileTargetWithMissingSignature(String className) throws Exception {
return compileTarget(className, "public MissingType broken(MissingType value) { return value; }", "MissingType");
}
private Class<?> compileTarget(String className, String methods, String missingType) throws Exception {
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
Assert.assertNotNull("JDK compiler is required to compile test classes", compiler);
File sourceRoot = temporaryFolder.newFolder();
int packageEnd = className.lastIndexOf('.');
String packageName = className.substring(0, packageEnd);
String simpleName = className.substring(packageEnd + 1);
File packageDir = new File(sourceRoot, packageName.replace('.', File.separatorChar));
Assert.assertTrue(packageDir.mkdirs());
File sourceFile = new File(packageDir, simpleName + ".java");
String source = "package " + packageName + ";\npublic class " + simpleName + " {\n" + methods + "\n}\n";
if (missingType != null) {
source += "class " + missingType + " {}\n";
}
Files.write(sourceFile.toPath(), source.getBytes(StandardCharsets.UTF_8));
File outputRoot = temporaryFolder.newFolder();
int exitCode = compiler.run(null, null, null, "-d", outputRoot.getAbsolutePath(), sourceFile.getAbsolutePath());
Assert.assertEquals(0, exitCode);
URLClassLoader classLoader = new URLClassLoader(new URL[] { outputRoot.toURI().toURL() }, null);
try {
Class<?> target = Class.forName(className, true, classLoader);
if (missingType != null) {
File missingClass = new File(packageDir(outputRoot, packageName), missingType + ".class");
Assert.assertTrue(missingClass.delete());
}
return target;
} finally {
classLoader.close();
}
}
private static File packageDir(File root, String packageName) {
return new File(root, packageName.replace('.', File.separatorChar));
}
private static RecordingCompletion completionFor(List<CliToken> tokens, Class<?>... classes) {
Session session = Mockito.mock(Session.class);
Mockito.when(session.getInstrumentation()).thenReturn(instrumentationFor(classes));
return new RecordingCompletion(session, tokens);
}
private static List<CliToken> methodCompletionTokens(String className, String methodPrefix) {
if (methodPrefix.length() == 0) {
return Arrays.<CliToken>asList(
new CliTokenImpl(true, "watch"),
new CliTokenImpl(false, " "),
new CliTokenImpl(true, className),
new CliTokenImpl(false, " "));
}
return Arrays.<CliToken>asList(
new CliTokenImpl(true, "watch"),
new CliTokenImpl(false, " "),
new CliTokenImpl(true, className),
new CliTokenImpl(false, " "),
new CliTokenImpl(true, methodPrefix));
}
private static Instrumentation instrumentationFor(final Class<?>... classes) {
return (Instrumentation) Proxy.newProxyInstance(
CompletionUtilsTest.class.getClassLoader(),
new Class<?>[] { Instrumentation.class },
(proxy, method, args) -> {
if ("getAllLoadedClasses".equals(method.getName())) {
return classes;
}
Class<?> returnType = method.getReturnType();
if (returnType == boolean.class) {
return false;
}
if (returnType == long.class) {
return 0L;
}
if (returnType == int.class) {
return 0;
}
if (returnType == Class[].class) {
return new Class<?>[0];
}
return null;
});
}
private static class RecordingCompletion implements Completion {
private final Session session;
private final List<CliToken> tokens;
private List<String> candidates;
private String value;
private boolean terminal;
private RecordingCompletion(Session session, List<CliToken> tokens) {
this.session = session;
this.tokens = tokens;
}
@Override
public Session session() {
return session;
}
@Override
public String rawLine() {
return "";
}
@Override
public List<CliToken> lineTokens() {
return tokens;
}
@Override
public void complete(List<String> candidates) {
this.candidates = candidates;
}
@Override
public void complete(String value, boolean terminal) {
this.value = value;
this.terminal = terminal;
}
}
}