Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,29 @@ public final class PluginPatcher {
private final ForkJoinPool forkJoinPool;
private final Path outputDir;
private final boolean verbose;
private final boolean parallel;
private final AtomicInteger patchedClassCount = new AtomicInteger();
private final AtomicInteger skippedClassCount = new AtomicInteger();
private final AtomicInteger failedClassCount = new AtomicInteger();
private final ConcurrentLinkedQueue<String> patchedClassNames = new ConcurrentLinkedQueue<>();
private final ConcurrentLinkedQueue<TransformationFailure> transformationFailures =
new ConcurrentLinkedQueue<>();

public PluginPatcher(Path outputDir, boolean verbose) {
this(outputDir, verbose, true);
}

/**
* @param parallel whether class transformations should use a ForkJoinPool. Browser runtimes can
* disable this to avoid worker/thread emulation overhead and improve stability.
*/
public PluginPatcher(Path outputDir, boolean verbose, boolean parallel) {
this.outputDir = outputDir;
this.verbose = verbose;
this.parallel = parallel;
int processors = Runtime.getRuntime().availableProcessors();
int parallelism = Math.max(1, processors > 2 ? processors - 1 : processors);
this.forkJoinPool = new ForkJoinPool(parallelism);
this.forkJoinPool = parallel ? new ForkJoinPool(parallelism) : null;
this.transformers = List.of(
new ThreadSafetyTransformer(),
new WorldGenClassTransformer(),
Expand All @@ -86,10 +99,13 @@ public Path patchPlugin(Path jarPath) throws IOException {
long startedAt = System.nanoTime();
patchedClassCount.set(0);
skippedClassCount.set(0);
failedClassCount.set(0);
patchedClassNames.clear();
transformationFailures.clear();
Files.createDirectories(outputDir);
Path outputPath = outputDir.resolve("patched-" + fileName);
log.info("Patching plugin: {} with {} worker(s)", fileName, forkJoinPool.getParallelism());
int workers = parallel ? forkJoinPool.getParallelism() : 1;
log.info("Patching plugin: {} with {} worker(s)", fileName, workers);

List<PreparedEntry> prepared = readAndPrepareEntries(jarPath);

Expand All @@ -111,8 +127,8 @@ public Path patchPlugin(Path jarPath) throws IOException {
}

long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
log.info("Patch complete for: {} (patched: {}, skipped: {}, elapsed: {} ms)",
fileName, patchedClassCount.get(), skippedClassCount.get(), elapsedMs);
log.info("Patch complete for: {} (patched: {}, skipped: {}, unchanged after errors: {}, elapsed: {} ms)",
fileName, patchedClassCount.get(), skippedClassCount.get(), failedClassCount.get(), elapsedMs);
return outputPath;
}

Expand All @@ -130,9 +146,13 @@ private List<PreparedEntry> readAndPrepareEntries(Path jarPath) throws IOExcepti
if ("plugin.yml".equals(name)) {
entries.add(PreparedEntry.direct(name, modifyPluginYml(content)));
} else if (name.endsWith(".class")) {
ForkJoinTask<byte[]> task = forkJoinPool.submit(
() -> transformClass(name, content));
entries.add(PreparedEntry.async(name, task));
if (parallel) {
ForkJoinTask<byte[]> task = forkJoinPool.submit(
() -> transformClass(name, content));
entries.add(PreparedEntry.async(name, task));
} else {
entries.add(PreparedEntry.direct(name, transformClass(name, content)));
}
} else {
entries.add(PreparedEntry.direct(name, content));
}
Expand All @@ -145,37 +165,48 @@ private byte[] transformClass(String entryName, byte[] classBytes) {
String className = entryName.substring(0, entryName.length() - ".class".length())
.replace('/', '.');

// Most library classes do not reference Bukkit at all. Avoid constructing ASM visitors for them.
if (!containsBytes(classBytes, BUKKIT_CONSTANT_POOL_MARKER) || !needsPatching(classBytes)) {
skippedClassCount.incrementAndGet();
if (verbose) {
log.debug("Skipping class (no patch needed): {}", className);
try {
// Most library classes do not reference Bukkit at all. Avoid constructing ASM visitors for them.
if (!containsBytes(classBytes, BUKKIT_CONSTANT_POOL_MARKER) || !needsPatching(classBytes)) {
skippedClassCount.incrementAndGet();
if (verbose) {
log.debug("Skipping class (no patch needed): {}", className);
}
return classBytes;
}
return classBytes;
}

ClassReader reader = new ClassReader(classBytes);
ClassNode classNode = new ClassNode(ASM_API);
reader.accept(classNode, ClassReader.EXPAND_FRAMES);

byte[] result = classBytes;
for (ClassTransformer transformer : transformers) {
ClassWriter writer = new SafeClassWriter(ClassWriter.COMPUTE_FRAMES);
byte[] transformed = transformer.transform(classNode, className, writer);
if (transformed != null) {
result = transformed;
reader = new ClassReader(result);
classNode = new ClassNode(ASM_API);
reader.accept(classNode, ClassReader.EXPAND_FRAMES);
ClassReader reader = new ClassReader(classBytes);
ClassNode classNode = new ClassNode(ASM_API);
reader.accept(classNode, ClassReader.EXPAND_FRAMES);

byte[] result = classBytes;
for (ClassTransformer transformer : transformers) {
ClassWriter writer = new SafeClassWriter(ClassWriter.COMPUTE_FRAMES);
byte[] transformed = transformer.transform(classNode, className, writer);
if (transformed != null) {
result = transformed;
reader = new ClassReader(result);
classNode = new ClassNode(ASM_API);
reader.accept(classNode, ClassReader.EXPAND_FRAMES);
}
}
}

patchedClassCount.incrementAndGet();
patchedClassNames.add(className);
if (verbose) {
log.debug("Patched class: {}", className);
patchedClassCount.incrementAndGet();
patchedClassNames.add(className);
if (verbose) {
log.debug("Patched class: {}", className);
}
return result;
} catch (RuntimeException | LinkageError exception) {
failedClassCount.incrementAndGet();
transformationFailures.add(new TransformationFailure(
className,
exception.getClass().getName(),
exception.getMessage() == null ? "" : exception.getMessage()));
log.warn("Leaving class unmodified after transform failure: {} ({})",
className, exception.toString());
return classBytes;
}
return result;
}

private boolean needsPatching(byte[] classBytes) {
Expand Down Expand Up @@ -256,10 +287,20 @@ public int getSkippedClassCount() {
return skippedClassCount.get();
}

public int getFailedClassCount() {
return failedClassCount.get();
}

public List<String> getPatchedClassNames() {
return patchedClassNames.stream().sorted().toList();
}

public List<TransformationFailure> getTransformationFailures() {
return transformationFailures.stream()
.sorted(java.util.Comparator.comparing(TransformationFailure::className))
.toList();
}

/**
* ASM normally loads referenced classes while computing stack map frames. Plugin dependencies
* such as Bukkit/Paper are intentionally absent from the CLI and browser runtime, so fall back
Expand All @@ -282,6 +323,9 @@ protected String getCommonSuperClass(String type1, String type2) {
}
}

public record TransformationFailure(String className, String exceptionType, String message) {
}

private record PreparedEntry(
String name,
byte[] directContent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,32 @@
public final class WebPatcher {

/**
* Patches one plugin and returns a compact text report.
* First line: patched-class count, tab, skipped-class count.
* Remaining lines: fully qualified names of patched classes.
* Patches one plugin and returns a compact tab-separated report.
* Header: patched count, skipped count, unchanged-after-error count.
* P-lines contain patched classes. F-lines contain classes that were left unchanged.
*/
public String patch(String inputPath) throws IOException {
PluginPatcher patcher = new PluginPatcher(Path.of("/files"), false);
// CheerpJ does not benefit from the desktop ForkJoinPool here. Keep ASM work sequential so
// one browser task owns the mutable class tree at a time and worker emulation cannot leak.
PluginPatcher patcher = new PluginPatcher(Path.of("/files"), false, false);
patcher.patchPlugin(Path.of(inputPath));

StringBuilder report = new StringBuilder()
.append(patcher.getPatchedClassCount())
.append('\t')
.append(patcher.getSkippedClassCount());
.append(patcher.getSkippedClassCount())
.append('\t')
.append(patcher.getFailedClassCount());

for (String className : patcher.getPatchedClassNames()) {
report.append('\n').append(className);
report.append('\n').append("P\t").append(cleanField(className));
}
for (PluginPatcher.TransformationFailure failure : patcher.getTransformationFailures()) {
report.append('\n')
.append("F\t")
.append(cleanField(failure.className())).append('\t')
.append(cleanField(failure.exceptionType())).append('\t')
.append(cleanField(failure.message()));
}
return report.toString();
}
Expand All @@ -32,4 +44,11 @@ public String patch(String inputPath) throws IOException {
public void delete(String path) throws IOException {
Files.deleteIfExists(Path.of(path));
}

private static String cleanField(String value) {
if (value == null) {
return "";
}
return value.replace('\t', ' ').replace('\r', ' ').replace('\n', ' ');
}
}
Loading
Loading