From 429eeb35876576d861404cd199b6e9763fc4e5b0 Mon Sep 17 00:00:00 2001 From: Luck Date: Sat, 26 May 2018 22:52:58 +0100 Subject: Initial commit for spark --- .../java/me/lucko/spark/common/CommandHandler.java | 287 +++++++++++++++++++++ .../java/me/lucko/spark/common/SamplerBuilder.java | 44 ++++ .../java/me/lucko/spark/common/http/Bytebin.java | 69 +++++ .../me/lucko/spark/common/http/HttpClient.java | 113 ++++++++ 4 files changed, 513 insertions(+) create mode 100644 common/src/main/java/me/lucko/spark/common/CommandHandler.java create mode 100644 common/src/main/java/me/lucko/spark/common/SamplerBuilder.java create mode 100644 common/src/main/java/me/lucko/spark/common/http/Bytebin.java create mode 100644 common/src/main/java/me/lucko/spark/common/http/HttpClient.java (limited to 'common/src/main/java/me') diff --git a/common/src/main/java/me/lucko/spark/common/CommandHandler.java b/common/src/main/java/me/lucko/spark/common/CommandHandler.java new file mode 100644 index 0000000..aa7e47d --- /dev/null +++ b/common/src/main/java/me/lucko/spark/common/CommandHandler.java @@ -0,0 +1,287 @@ +package me.lucko.spark.common; + +import com.google.gson.JsonObject; +import com.sk89q.warmroast.ThreadDumper; +import com.sk89q.warmroast.Sampler; + +import me.lucko.spark.common.http.Bytebin; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Timer; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Abstract command handling class used by all platforms. + * + * @param the sender (e.g. CommandSender) type used by the platform + */ +public abstract class CommandHandler { + + /** The URL of the viewer frontend */ + private static final String VIEWER_URL = "https://sparkprofiler.github.io/?"; + + /** + * The {@link Timer} being used by the {@link #activeSampler}. + */ + private final Timer timer = new Timer("spark-sampling-thread", true); + + /** Guards {@link #activeSampler} */ + private final Object[] activeSamplerMutex = new Object[0]; + /** The WarmRoast instance currently running, if any */ + private Sampler activeSampler = null; + + + // abstract methods implemented by each platform + + protected abstract void sendMessage(T sender, String message); + protected abstract void runAsync(Runnable r); + + private void sendPrefixedMessage(T sender, String message) { + sendMessage(sender, "&8[&fspark&8] &7" + message); + } + + public void handleCommand(T sender, String[] args) { + try { + if (args.length == 0) { + sendInfo(sender); + return; + } + + List arguments = new ArrayList<>(Arrays.asList(args)); + switch (arguments.remove(0).toLowerCase()) { + case "start": + handleStart(sender, arguments); + break; + case "info": + handleInfo(sender); + break; + case "cancel": + handleCancel(sender); + break; + case "stop": + case "upload": + case "paste": + handleStop(sender); + break; + default: + sendInfo(sender); + break; + } + } catch (IllegalArgumentException e) { + sendMessage(sender, "&c" + e.getMessage()); + } + } + + private void sendInfo(T sender) { + sendPrefixedMessage(sender, "&fspark profiler &7v1.0"); + sendMessage(sender, "&b&l> &7/profiler start"); + sendMessage(sender, " &8[&7--timeout&8 ]"); + sendMessage(sender, " &8[&7--thread&8 ]"); + sendMessage(sender, " &8[&7--interval&8 ]"); + sendMessage(sender, "&b&l> &7/profiler info"); + sendMessage(sender, "&b&l> &7/profiler stop"); + sendMessage(sender, "&b&l> &7/profiler cancel"); + } + + private void handleStart(T sender, List args) { + Map arguments = parseArguments(args); + + int timeoutSeconds = parseInt(arguments, "timeout", "d"); + if (timeoutSeconds != -1 && timeoutSeconds <= 10) { + sendPrefixedMessage(sender, "&cThe specified timeout is not long enough for accurate results to be formed."); + return; + } + + if (timeoutSeconds != -1 && timeoutSeconds < 100) { + sendPrefixedMessage(sender, "&7The accuracy of the output will significantly improve when sampling is able to run for longer periods. Consider setting a value of timeout over 1-2 minutes."); + } + + int intervalMillis = parseInt(arguments, "interval", "i"); + if (intervalMillis == -1) { + intervalMillis = 10; + } + + String threadName = arguments.getOrDefault("thread", arguments.getOrDefault("t", null)); + ThreadDumper threadDumper; + if (threadName == null) { + // use the server thread + threadDumper = new ThreadDumper.Specific(new long[]{Thread.currentThread().getId()}); + } else if (threadName.equals("*")) { + threadDumper = new ThreadDumper.All(); + } else { + threadDumper = new ThreadDumper.Specific(threadName); + } + + Sampler sampler; + synchronized (this.activeSamplerMutex) { + if (this.activeSampler != null) { + sendPrefixedMessage(sender, "&7An active sampler is already running."); + return; + } + + sendPrefixedMessage(sender, "&7Starting a new sampler task..."); + + SamplerBuilder builder = new SamplerBuilder(); + builder.threadDumper(threadDumper); + if (timeoutSeconds != -1) { + builder.completeAfter(timeoutSeconds, TimeUnit.SECONDS); + } + builder.samplingInterval(intervalMillis); + sampler = this.activeSampler = builder.start(timer); + + sendPrefixedMessage(sender, "&bSampling has begun!"); + } + + CompletableFuture future = sampler.getFuture(); + + // send message if profiling fails + future.whenComplete((s, throwable) -> { + if (throwable != null) { + sendPrefixedMessage(sender, "&cSampling operation failed unexpectedly. Error: " + throwable.toString()); + throwable.printStackTrace(); + } + }); + + // set activeSampler to null when complete. + future.whenComplete((s, throwable) -> { + synchronized (this.activeSamplerMutex) { + if (sampler == this.activeSampler) { + this.activeSampler = null; + } + } + }); + + // await the result + if (timeoutSeconds != -1) { + future.thenAcceptAsync(s -> { + sendPrefixedMessage(sender, "&7The active sampling operation has completed! Uploading results..."); + handleUpload(sender, s); + }); + } + } + + private void handleInfo(T sender) { + synchronized (this.activeSamplerMutex) { + if (this.activeSampler == null) { + sendPrefixedMessage(sender, "&7There isn't an active sampling task running."); + } else { + long timeout = this.activeSampler.getEndTime(); + if (timeout == -1) { + sendPrefixedMessage(sender, "&7There is an active sampler currently running, with no defined timeout."); + } else { + long timeoutDiff = (timeout - System.currentTimeMillis()) / 1000L; + sendPrefixedMessage(sender, "&7There is an active sampler currently running, due to timeout in " + timeoutDiff + " seconds."); + } + + long runningTime = (System.currentTimeMillis() - this.activeSampler.getStartTime()) / 1000L; + sendPrefixedMessage(sender, "&7It has been sampling for " + runningTime + " seconds so far."); + } + } + } + + private void handleStop(T sender) { + synchronized (this.activeSamplerMutex) { + if (this.activeSampler == null) { + sendPrefixedMessage(sender, "&7There isn't an active sampling task running."); + } else { + this.activeSampler.cancel(); + sendPrefixedMessage(sender, "&7The active sampling operation has been stopped! Uploading results..."); + handleUpload(sender, this.activeSampler); + this.activeSampler = null; + } + } + } + + private void handleCancel(T sender) { + synchronized (this.activeSamplerMutex) { + if (this.activeSampler == null) { + sendPrefixedMessage(sender, "&7There isn't an active sampling task running."); + } else { + this.activeSampler.cancel(); + this.activeSampler = null; + sendPrefixedMessage(sender, "&bThe active sampling task has been cancelled."); + } + } + } + + private void handleUpload(T sender, Sampler sampler) { + runAsync(() -> { + JsonObject output = sampler.formOutput(); + try { + String pasteId = Bytebin.postContent(output); + sendPrefixedMessage(sender, "&bSampling results can be viewed here: &7" + VIEWER_URL + pasteId); + } catch (IOException e) { + sendPrefixedMessage(sender, "&cAn error occurred whilst uploading the results."); + e.printStackTrace(); + } + }); + } + + private int parseInt(Map arguments, String longArg, String shortArg) { + String value = arguments.getOrDefault(longArg, arguments.getOrDefault(shortArg, null)); + if (value != null) { + try { + return Math.abs(Integer.parseInt(value)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid input for '" + longArg + "' argument. Please specify a number!"); + } + } else { + return -1; // undefined + } + } + + private static final Pattern FLAG_REGEX = Pattern.compile("--(.+)$|-([a-zA-z])$"); + + private static Map parseArguments(List args) { + Map arguments = new HashMap<>(); + + String flag = null; + List value = null; + + for (int i = 0; i < args.size(); i++) { + String arg = args.get(i); + + Matcher matcher = FLAG_REGEX.matcher(arg); + boolean matches = matcher.matches(); + + if (flag == null || matches) { + if (!matches) { + throw new IllegalArgumentException("Expected flag at position " + i + " but got '" + arg + "' instead!"); + } + + String match = matcher.group(1); + if (match == null) { + match = matcher.group(2); + } + + // store existing value, if present + if (flag != null) { + arguments.put(flag, value.stream().collect(Collectors.joining(" "))); + } + + flag = match.toLowerCase(); + value = new ArrayList<>(); + } else { + // part of a value + value.add(arg); + } + } + + // store remaining value, if present + if (flag != null) { + arguments.put(flag, value.stream().collect(Collectors.joining(" "))); + } + + return arguments; + } + +} diff --git a/common/src/main/java/me/lucko/spark/common/SamplerBuilder.java b/common/src/main/java/me/lucko/spark/common/SamplerBuilder.java new file mode 100644 index 0000000..e6c0cf8 --- /dev/null +++ b/common/src/main/java/me/lucko/spark/common/SamplerBuilder.java @@ -0,0 +1,44 @@ +package me.lucko.spark.common; + +import com.google.common.base.Preconditions; +import com.sk89q.warmroast.ThreadDumper; +import com.sk89q.warmroast.Sampler; + +import java.util.Timer; +import java.util.concurrent.TimeUnit; + +/** + * Builds {@link Sampler} instances. + */ +public class SamplerBuilder { + + private int samplingInterval = 10; + private long timeout = -1; + private ThreadDumper threadDumper = new ThreadDumper.All(); + + public SamplerBuilder() { + } + + public SamplerBuilder samplingInterval(int samplingInterval) { + this.samplingInterval = samplingInterval; + return this; + } + + public SamplerBuilder completeAfter(long timeout, TimeUnit unit) { + Preconditions.checkArgument(timeout > 0, "time > 0"); + this.timeout = System.currentTimeMillis() + unit.toMillis(timeout); + return this; + } + + public SamplerBuilder threadDumper(ThreadDumper threadDumper) { + this.threadDumper = threadDumper; + return this; + } + + public Sampler start(Timer timer) { + Sampler sampler = new Sampler(samplingInterval, threadDumper, timeout); + sampler.start(timer); + return sampler; + } + +} diff --git a/common/src/main/java/me/lucko/spark/common/http/Bytebin.java b/common/src/main/java/me/lucko/spark/common/http/Bytebin.java new file mode 100644 index 0000000..7d95838 --- /dev/null +++ b/common/src/main/java/me/lucko/spark/common/http/Bytebin.java @@ -0,0 +1,69 @@ +package me.lucko.spark.common.http; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.zip.GZIPOutputStream; + +/** + * Utility for uploading JSON data to bytebin. + */ +public final class Bytebin { + + /** Shared GSON instance */ + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + /** Media type for JSON data */ + private static final MediaType JSON_TYPE = MediaType.parse("application/json; charset=utf-8"); + /** The URL used to upload sampling data */ + private static final String UPLOAD_ENDPOINT = "https://bytebin.lucko.me/post"; + + public static String postContent(JsonElement content) throws IOException { + ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); + try (Writer writer = new OutputStreamWriter(new GZIPOutputStream(byteOut), StandardCharsets.UTF_8)) { + GSON.toJson(content, writer); + } catch (IOException e) { + throw new RuntimeException(e); + } + + RequestBody body = RequestBody.create(JSON_TYPE, byteOut.toByteArray()); + + Request.Builder requestBuilder = new Request.Builder() + .url(UPLOAD_ENDPOINT) + .header("Content-Encoding", "gzip") + .post(body); + + Request request = requestBuilder.build(); + try (Response response = HttpClient.makeCall(request)) { + try (ResponseBody responseBody = response.body()) { + if (responseBody == null) { + throw new RuntimeException("No response"); + } + + try (InputStream inputStream = responseBody.byteStream()) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + JsonObject object = new Gson().fromJson(reader, JsonObject.class); + return object.get("key").getAsString(); + } + } + } + } + } + + private Bytebin() {} +} diff --git a/common/src/main/java/me/lucko/spark/common/http/HttpClient.java b/common/src/main/java/me/lucko/spark/common/http/HttpClient.java new file mode 100644 index 0000000..61db597 --- /dev/null +++ b/common/src/main/java/me/lucko/spark/common/http/HttpClient.java @@ -0,0 +1,113 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.spark.common.http; + +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import java.io.IOException; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.util.Collections; +import java.util.List; + +/** + * Utility class for making http requests. + */ +public final class HttpClient { + private static OkHttpClient client = null; + + private static synchronized OkHttpClient getClient() { + if (client == null) { + client = new OkHttpClient.Builder() + .proxySelector(new NullSafeProxySelector()) + .addInterceptor(new UserAgentInterceptor()) + .build(); + } + return client; + } + + public static Response makeCall(Request request) throws IOException { + Response response = getClient().newCall(request).execute(); + if (!response.isSuccessful()) { + throw exceptionForUnsuccessfulResponse(response); + } + return response; + } + + private static RuntimeException exceptionForUnsuccessfulResponse(Response response) { + String msg = ""; + try (ResponseBody responseBody = response.body()) { + if (responseBody != null) { + msg = responseBody.string(); + } + } catch (IOException e) { + // ignore + } + return new RuntimeException("Got response: " + response.code() + " - " + response.message() + " - " + msg); + } + + private static final class UserAgentInterceptor implements Interceptor { + @Override + public Response intercept(Chain chain) throws IOException { + Request orig = chain.request(); + Request modified = orig.newBuilder() + .header("User-Agent", "spark-plugin") + .build(); + + return chain.proceed(modified); + } + } + + // sometimes ProxySelector#getDefault returns null, and okhttp doesn't like that + private static final class NullSafeProxySelector extends ProxySelector { + private static final List DIRECT = Collections.singletonList(Proxy.NO_PROXY); + + @Override + public List select(URI uri) { + ProxySelector def = ProxySelector.getDefault(); + if (def == null) { + return DIRECT; + } + return def.select(uri); + } + + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { + ProxySelector def = ProxySelector.getDefault(); + if (def != null) { + def.connectFailed(uri, sa, ioe); + } + } + } + + private HttpClient() {} +} \ No newline at end of file -- cgit