From 9f394b343da3b0dec32577ea2549e6b7a14f8c31 Mon Sep 17 00:00:00 2001 From: soloturn Date: Wed, 7 Oct 2026 14:33:33 +0200 Subject: [PATCH 1/4] fix(reporter): upload to PasteBin via HttpClient with real timeouts (#71) jpastebin's URLConnection had no timeouts, so a hung upload outlived the dialog's failure. One form POST via HttpClient now carries 30s connect/read timeouts that actually abort. Drops jpastebin, the JBoss repo entries and the Jackson workaround. Test uses a loopback server, no real network. Co-Authored-By: Claude Sonnet 5.5 --- cr-core/build.gradle.kts | 24 ---- cr-core/gradle.lockfile | 51 ++++----- .../pages/PastebinUploadRunnable.java | 65 ++++++++--- .../crashreporter/pages/UploadPanel.java | 6 +- .../pages/PastebinUploadRunnableTest.java | 106 ++++++++++++++++++ cr-destsol/build.gradle.kts | 9 -- cr-destsol/gradle.lockfile | 5 - cr-terasology/build.gradle.kts | 9 -- cr-terasology/gradle.lockfile | 5 - 9 files changed, 182 insertions(+), 98 deletions(-) create mode 100644 cr-core/src/test/java/org/terasology/crashreporter/pages/PastebinUploadRunnableTest.java diff --git a/cr-core/build.gradle.kts b/cr-core/build.gradle.kts index 06115ca..ef5e9c0 100644 --- a/cr-core/build.gradle.kts +++ b/cr-core/build.gradle.kts @@ -35,15 +35,6 @@ repositories { excludeGroupByRegex("org.terasology(..+)?") } } - // JBoss Maven Repository requried to fetch `org.jpastebin` dependency for CrashReporter - // https://developer.jboss.org/docs/DOC-11377 - maven { - name = "JBoss Public Maven Repository Group" - url = uri("https://repository.jboss.org/nexus/content/repositories/public/") - content { - includeModule("org", "jpastebin") - } - } maven { name = "Terasology Artifactory" url = uri("https://artifactory.terasology.io/artifactory/virtual-repo-live") @@ -61,21 +52,6 @@ dependencies { pmd("net.sourceforge.pmd:pmd-core:7.0.0-rc4") pmd("net.sourceforge.pmd:pmd-java:7.0.0-rc4") - implementation("org:jpastebin:1.0.1") - // jpastebin needs these at runtime (see its own embedded META-INF/maven/org/jpastebin/pom.xml, - // which pins Jackson 2.9.7) but the POM Gradle actually resolves from the JBoss repo is an - // empty Nexus-generated stub with no at all - so without declaring these - // ourselves, PastebinUploadRunnable.call() throws NoClassDefFoundError the first time it - // touches a Jackson class, only when someone actually clicks "Upload". 2.9.7 is a 2018 release - // with known CVEs; Jackson's 2.x line keeps this level of API (ObjectMapper, TypeReference, - // annotations) stable, so a current release is a safe drop-in rather than matching the old pin. - // The BOM (not three separately-pinned versions) because jackson-annotations renumbered its own - // versioning away from core/databind's x.y.z scheme starting at 2.20 - the BOM is what keeps the - // three resolvable together regardless of a given module's own version string. - implementation(platform("com.fasterxml.jackson:jackson-bom:2.22.2")) - implementation("com.fasterxml.jackson.core:jackson-databind") - implementation("com.fasterxml.jackson.core:jackson-core") - implementation("com.fasterxml.jackson.core:jackson-annotations") implementation("org.apache.httpcomponents:httpclient:4.5.13") implementation("org.apache.httpcomponents:httpmime:4.5.13") // GitHub issue-creation API request/response bodies (GitHubIssueApiClient) - small, diff --git a/cr-core/gradle.lockfile b/cr-core/gradle.lockfile index fa69e8d..3d242fe 100644 --- a/cr-core/gradle.lockfile +++ b/cr-core/gradle.lockfile @@ -4,26 +4,22 @@ # To regenerate this file, run: ./gradlew :cr-core:dependencies --write-locks ch.qos.logback:logback-classic:1.6.0=testRuntimeClasspath ch.qos.logback:logback-core:1.6.0=testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.22.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.22.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.22.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.oowekyala.ooxml:nice-xml-messages:3.1=pmd -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,mainPmdAuxClasspath,runtimeClasspath,testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath com.google.code.gson:gson:2.8.9=pmd -com.google.errorprone:error_prone_annotations:2.11.0=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.google.guava:failureaccess:1.0.1=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.google.guava:guava:31.1-jre=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.google.j2objc:j2objc-annotations:1.3=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.11.0=checkstyle,compileClasspath,mainPmdAuxClasspath,runtimeClasspath,testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.1=checkstyle,compileClasspath,mainPmdAuxClasspath,runtimeClasspath,testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath +com.google.guava:guava:31.1-jre=checkstyle,compileClasspath,mainPmdAuxClasspath,runtimeClasspath,testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=checkstyle,compileClasspath,mainPmdAuxClasspath,runtimeClasspath,testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:1.3=checkstyle,compileClasspath,mainPmdAuxClasspath,runtimeClasspath,testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:10.2=checkstyle commons-beanutils:commons-beanutils:1.9.4=checkstyle -commons-codec:commons-codec:1.11=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-codec:commons-codec:1.11=checkstyle,compileClasspath,mainPmdAuxClasspath,runtimeClasspath,testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle -commons-logging:commons-logging:1.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.2=compileClasspath,mainPmdAuxClasspath,runtimeClasspath,testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath info.picocli:picocli:4.6.3=checkstyle -net.bytebuddy:byte-buddy-agent:1.17.7=testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.17.7=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.17.7=testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.7=testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:10.7=pmd net.sf.saxon:Saxon-HE:11.3=checkstyle net.sourceforge.pmd:pmd-ant:7.0.0-rc4=pmd @@ -32,30 +28,29 @@ net.sourceforge.pmd:pmd-java:7.0.0-rc4=pmd org.antlr:antlr4-runtime:4.10.1=checkstyle org.antlr:antlr4-runtime:4.9.1=pmd org.apache.commons:commons-lang3:3.8.1=pmd -org.apache.httpcomponents:httpclient:4.5.13=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.httpcomponents:httpcore:4.4.13=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.httpcomponents:httpmime:4.5.13=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents:httpclient:4.5.13=checkstyle,compileClasspath,mainPmdAuxClasspath,runtimeClasspath,testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath +org.apache.httpcomponents:httpcore:4.4.13=checkstyle,compileClasspath,mainPmdAuxClasspath,runtimeClasspath,testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath +org.apache.httpcomponents:httpmime:4.5.13=compileClasspath,mainPmdAuxClasspath,runtimeClasspath,testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.checkerframework:checker-qual:2.5.2=pmd -org.checkerframework:checker-qual:3.12.0=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.12.0=checkstyle,compileClasspath,mainPmdAuxClasspath,runtimeClasspath,testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.json:json:20260814=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:5.10.1=testCompileClasspath,testRuntimeClasspath +org.json:json:20260814=compileClasspath,mainPmdAuxClasspath,runtimeClasspath,testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.10.1=testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:5.10.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:5.10.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:1.10.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.10.1=testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.10.1=testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath org.junit.platform:junit-platform-engine:1.10.1=testRuntimeClasspath org.junit.platform:junit-platform-launcher:1.10.1=testRuntimeClasspath -org.junit:junit-bom:5.10.1=testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-core:5.23.0=testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=testRuntimeClasspath -org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:5.10.1=testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.23.0=testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testPmdAuxClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath org.ow2.asm:asm:9.5=pmd org.pcollections:pcollections:3.1.3=pmd org.reflections:reflections:0.10.2=checkstyle org.slf4j:jul-to-slf4j:1.7.36=pmd -org.slf4j:slf4j-api:2.0.18=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=testCompileClasspath,testPmdAuxClasspath,testRuntimeClasspath org.terasology.config:codemetrics:2.2.0=codeMetrics org.xmlresolver:xmlresolver:4.2.0=checkstyle -org:jpastebin:1.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath empty=annotationProcessor,testAnnotationProcessor diff --git a/cr-core/src/main/java/org/terasology/crashreporter/pages/PastebinUploadRunnable.java b/cr-core/src/main/java/org/terasology/crashreporter/pages/PastebinUploadRunnable.java index 79de58c..e2db7f7 100644 --- a/cr-core/src/main/java/org/terasology/crashreporter/pages/PastebinUploadRunnable.java +++ b/cr-core/src/main/java/org/terasology/crashreporter/pages/PastebinUploadRunnable.java @@ -3,13 +3,23 @@ package org.terasology.crashreporter.pages; -import org.jpastebin.exceptions.PasteException; -import org.jpastebin.pastebin.PasteExpireDate; -import org.jpastebin.pastebin.Pastebin; -import org.jpastebin.pastebin.PastebinLink; -import org.jpastebin.pastebin.PastebinPaste; +import org.apache.http.HttpStatus; +import org.apache.http.NameValuePair; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.util.EntityUtils; +import java.io.IOException; +import java.net.URI; import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; import java.util.concurrent.Callable; /** @@ -17,6 +27,9 @@ */ public class PastebinUploadRunnable implements Callable { + static final String PASTEBIN_API_URL = "https://pastebin.com/api/api_post.php"; + static final int TIMEOUT_MILLIS = 30_000; + /** * Username Terasology * eMail pastebin@terasology.org @@ -24,20 +37,44 @@ public class PastebinUploadRunnable implements Callable { private static final String PASTEBIN_DEVELOPER_KEY = "1ed92217030bd6c2570fac91bcbfee78"; private final String content; + private final URI apiUri; + private final int timeoutMillis; public PastebinUploadRunnable(String content) { + this(content, URI.create(PASTEBIN_API_URL), TIMEOUT_MILLIS); + } + + PastebinUploadRunnable(String content, URI apiUri, int timeoutMillis) { this.content = content; + this.apiUri = apiUri; + this.timeoutMillis = timeoutMillis; } @Override - public URL call() throws PasteException { - String title = "Terasology Error Report"; - final PastebinPaste paste = Pastebin.newPaste(PASTEBIN_DEVELOPER_KEY, content, title); - paste.setPasteFormat("apache"); // Apache Log File Format - this is the closest I could find - paste.setPasteExpireDate(PasteExpireDate.ONE_MONTH); - - PastebinLink link = paste.paste(); - final URL url = link.getLink(); - return url; + public URL call() throws IOException { + RequestConfig config = RequestConfig.custom() + .setConnectTimeout(timeoutMillis) + .setConnectionRequestTimeout(timeoutMillis) + .setSocketTimeout(timeoutMillis) + .build(); + List form = Arrays.asList( + new BasicNameValuePair("api_dev_key", PASTEBIN_DEVELOPER_KEY), + new BasicNameValuePair("api_option", "paste"), + new BasicNameValuePair("api_paste_code", content), + new BasicNameValuePair("api_paste_name", "Terasology Error Report"), + new BasicNameValuePair("api_paste_format", "apache"), // closest to a log file format + new BasicNameValuePair("api_paste_expire_date", "1M")); + + try (CloseableHttpClient client = HttpClientBuilder.create().setDefaultRequestConfig(config).build()) { + HttpPost post = new HttpPost(apiUri); + post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8)); + try (CloseableHttpResponse response = client.execute(post)) { + String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8).trim(); + if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK || !body.startsWith("http")) { + throw new IOException(body); // PasteBin reports errors as "Bad API request, ..." text + } + return new URL(body); + } + } } } diff --git a/cr-core/src/main/java/org/terasology/crashreporter/pages/UploadPanel.java b/cr-core/src/main/java/org/terasology/crashreporter/pages/UploadPanel.java index 65fa271..1fcda17 100644 --- a/cr-core/src/main/java/org/terasology/crashreporter/pages/UploadPanel.java +++ b/cr-core/src/main/java/org/terasology/crashreporter/pages/UploadPanel.java @@ -142,10 +142,8 @@ public URL getUploadedFileURL() { /** * Runs {@code callable} on its own thread and waits up to {@link #uploadTimeoutSeconds} for it - * to finish - {@code PastebinUploadRunnable} makes a real HTTP call with no timeout of its own, - * so without one here a slow or unreachable server leaves the button disabled and the status - * label reading "please wait" forever, with no way for the user to tell the difference between - * "still working" and "will never finish". + * to finish, so a slow or unreachable server can't leave the button disabled and the status + * label reading "please wait" forever. */ private void upload(final Callable callable) { final ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactory() { diff --git a/cr-core/src/test/java/org/terasology/crashreporter/pages/PastebinUploadRunnableTest.java b/cr-core/src/test/java/org/terasology/crashreporter/pages/PastebinUploadRunnableTest.java new file mode 100644 index 0000000..9cf9282 --- /dev/null +++ b/cr-core/src/test/java/org/terasology/crashreporter/pages/PastebinUploadRunnableTest.java @@ -0,0 +1,106 @@ +// Copyright 2026 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 + +package org.terasology.crashreporter.pages; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Loopback-only: no test touches the real network. */ +class PastebinUploadRunnableTest { + + private HttpServer server; + private final CountDownLatch release = new CountDownLatch(1); + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + } + + @AfterEach + void stopServer() { + release.countDown(); + server.stop(0); + } + + private URI uri() { + return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/api/api_post.php"); + } + + @Test + void postsFormAndReturnsPasteUrl() throws Exception { + AtomicReference requestBody = new AtomicReference<>(); + server.createContext("/api/api_post.php", exchange -> { + requestBody.set(new String(readAll(exchange.getRequestBody()), StandardCharsets.UTF_8)); + reply(exchange, 200, "https://pastebin.com/abc123"); + }); + server.start(); + + URL url = new PastebinUploadRunnable("a log & more", uri(), 5000).call(); + + assertEquals("https://pastebin.com/abc123", url.toString()); + assertTrue(requestBody.get().contains("api_option=paste")); + assertTrue(requestBody.get().contains("api_paste_code=a+log+%26+more")); + } + + @Test + void apiErrorTextBecomesIoException() { + server.createContext("/api/api_post.php", exchange -> reply(exchange, 200, "Bad API request, invalid api_dev_key")); + server.start(); + + IOException e = assertThrows(IOException.class, () -> new PastebinUploadRunnable("x", uri(), 5000).call()); + assertEquals("Bad API request, invalid api_dev_key", e.getMessage()); + } + + @Test + void hungServerTimesOutInsteadOfBlocking() { + server.createContext("/api/api_post.php", exchange -> { + try { + release.await(); // never answers until the test ends + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + }); + server.start(); + + long start = System.nanoTime(); + assertThrows(SocketTimeoutException.class, () -> new PastebinUploadRunnable("x", uri(), 300).call()); + assertTrue((System.nanoTime() - start) / 1_000_000 < 5000, "timeout must actually stop the request"); + } + + private static void reply(com.sun.net.httpserver.HttpExchange exchange, int code, String text) throws IOException { + byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(code, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } + + private static byte[] readAll(java.io.InputStream in) throws IOException { + java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream(); + byte[] chunk = new byte[4096]; + int n = in.read(chunk); + while (n > 0) { + buf.write(chunk, 0, n); + n = in.read(chunk); + } + return buf.toByteArray(); + } +} diff --git a/cr-destsol/build.gradle.kts b/cr-destsol/build.gradle.kts index 5ddc761..dbc519d 100644 --- a/cr-destsol/build.gradle.kts +++ b/cr-destsol/build.gradle.kts @@ -18,15 +18,6 @@ repositories { excludeGroupByRegex("org.terasology(..+)?") } } - // JBoss Maven Repository requried to fetch `org.jpastebin` dependency for CrashReporter - // https://developer.jboss.org/docs/DOC-11377 - maven { - name = "JBoss Public Maven Repository Group" - url = uri("https://repository.jboss.org/nexus/content/repositories/public/") - content { - includeModule("org", "jpastebin") - } - } maven { name = "Terasology Artifactory" url = uri("https://artifactory.terasology.io/artifactory/virtual-repo-live") diff --git a/cr-destsol/gradle.lockfile b/cr-destsol/gradle.lockfile index 296dfa5..0fb6278 100644 --- a/cr-destsol/gradle.lockfile +++ b/cr-destsol/gradle.lockfile @@ -2,10 +2,6 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. # To regenerate this file, run: ./gradlew :cr-destsol:dependencies --write-locks -com.fasterxml.jackson.core:jackson-annotations:2.22=runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.22.2=runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.22.2=runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.22.2=runtimeClasspath,testRuntimeClasspath com.google.code.findbugs:jsr305:3.0.2=runtimeClasspath,testRuntimeClasspath com.google.errorprone:error_prone_annotations:2.11.0=runtimeClasspath,testRuntimeClasspath com.google.guava:failureaccess:1.0.1=runtimeClasspath,testRuntimeClasspath @@ -19,5 +15,4 @@ org.apache.httpcomponents:httpcore:4.4.13=runtimeClasspath,testRuntimeClasspath org.apache.httpcomponents:httpmime:4.5.13=runtimeClasspath,testRuntimeClasspath org.checkerframework:checker-qual:3.12.0=runtimeClasspath,testRuntimeClasspath org.json:json:20260814=runtimeClasspath,testRuntimeClasspath -org:jpastebin:1.0.1=runtimeClasspath,testRuntimeClasspath empty=annotationProcessor,compileClasspath,testAnnotationProcessor,testCompileClasspath diff --git a/cr-terasology/build.gradle.kts b/cr-terasology/build.gradle.kts index 904ba3f..9a2e956 100644 --- a/cr-terasology/build.gradle.kts +++ b/cr-terasology/build.gradle.kts @@ -18,15 +18,6 @@ repositories { excludeGroupByRegex("org.terasology(..+)?") } } - // JBoss Maven Repository requried to fetch `org.jpastebin` dependency for CrashReporter - // https://developer.jboss.org/docs/DOC-11377 - maven { - name = "JBoss Public Maven Repository Group" - url = uri("https://repository.jboss.org/nexus/content/repositories/public/") - content { - includeModule("org", "jpastebin") - } - } maven { name = "Terasology Artifactory" url = uri("https://artifactory.terasology.io/artifactory/virtual-repo-live") diff --git a/cr-terasology/gradle.lockfile b/cr-terasology/gradle.lockfile index eb60ab8..d4e8198 100644 --- a/cr-terasology/gradle.lockfile +++ b/cr-terasology/gradle.lockfile @@ -2,10 +2,6 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. # To regenerate this file, run: ./gradlew :cr-terasology:dependencies --write-locks -com.fasterxml.jackson.core:jackson-annotations:2.22=runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.22.2=runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.22.2=runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.22.2=runtimeClasspath,testRuntimeClasspath com.google.code.findbugs:jsr305:3.0.2=runtimeClasspath,testRuntimeClasspath com.google.errorprone:error_prone_annotations:2.11.0=runtimeClasspath,testRuntimeClasspath com.google.guava:failureaccess:1.0.1=runtimeClasspath,testRuntimeClasspath @@ -19,5 +15,4 @@ org.apache.httpcomponents:httpcore:4.4.13=runtimeClasspath,testRuntimeClasspath org.apache.httpcomponents:httpmime:4.5.13=runtimeClasspath,testRuntimeClasspath org.checkerframework:checker-qual:3.12.0=runtimeClasspath,testRuntimeClasspath org.json:json:20260814=runtimeClasspath,testRuntimeClasspath -org:jpastebin:1.0.1=runtimeClasspath,testRuntimeClasspath empty=annotationProcessor,compileClasspath,testAnnotationProcessor,testCompileClasspath From 81f821e8a6b777fc70df9d4b0d48e88c5b38dd2a Mon Sep 17 00:00:00 2001 From: soloturn Date: Wed, 7 Oct 2026 19:01:31 +0200 Subject: [PATCH 2/4] feat(reporter): mark generated issue text as coming from CrashReporter One-line "Generated by CrashReporter" quote now opens the generic body and the form's "What actually happened" field, so it also reaches direct submit. Co-Authored-By: Claude Sonnet 5.5 --- .../org/terasology/crashreporter/pages/CrashSummary.java | 9 ++++++--- .../terasology/crashreporter/pages/CrashSummaryTest.java | 8 ++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/cr-core/src/main/java/org/terasology/crashreporter/pages/CrashSummary.java b/cr-core/src/main/java/org/terasology/crashreporter/pages/CrashSummary.java index c824ca4..3b244db 100644 --- a/cr-core/src/main/java/org/terasology/crashreporter/pages/CrashSummary.java +++ b/cr-core/src/main/java/org/terasology/crashreporter/pages/CrashSummary.java @@ -46,6 +46,9 @@ public final class CrashSummary { private static final int MAX_STACK_LINES = 15; private static final int MAX_MODULES_LISTED = 30; private static final int MAX_TITLE_MESSAGE_LENGTH = 80; + + /** First line of every generated issue text, so readers know where it came from. */ + static final String DISCLAIMER = "> _Generated by CrashReporter_"; private static final int MAX_EXCEPTIONS_LISTED = 10; private static final int CONTEXT_LINES_BEFORE = 5; private static final String NO_TAB_LABEL = "this crash"; @@ -321,9 +324,9 @@ public String buildTitle() { * log tab it was found in), then environment info, then a link to the full logs */ public String buildBody(URL pastebinLink) { - StringBuilder body = new StringBuilder(); + StringBuilder body = new StringBuilder(DISCLAIMER).append("\n\n"); - // The link goes first: when this body is squeezed into a URL, GitHubIssueLinkBuilder + // The link goes next: when this body is squeezed into a URL, GitHubIssueLinkBuilder // truncates the tail, and the one line that must survive a truncated trace is the pointer // to the full log. body.append("### Full logs\n\n"); @@ -388,7 +391,7 @@ public Map buildIssueFormFields(URL pastebinLink) { + " (" + System.getProperty("os.arch") + ")"); putField(fields, Profile.FormField.JAVA, System.getProperty("java.version")); - StringBuilder actual = new StringBuilder(); + StringBuilder actual = new StringBuilder(DISCLAIMER).append("\n\n"); for (String block : exceptionBlocks) { actual.append(block).append("\n\n"); } diff --git a/cr-core/src/test/java/org/terasology/crashreporter/pages/CrashSummaryTest.java b/cr-core/src/test/java/org/terasology/crashreporter/pages/CrashSummaryTest.java index 490c587..47708e0 100644 --- a/cr-core/src/test/java/org/terasology/crashreporter/pages/CrashSummaryTest.java +++ b/cr-core/src/test/java/org/terasology/crashreporter/pages/CrashSummaryTest.java @@ -342,6 +342,14 @@ void issueFormFieldsIncludeVersionOsAndTheExceptionBlocks() { assertTrue(fields.get("actual_behavior").contains("RuntimeException: boom"), fields.get("actual_behavior")); } + @Test + void bodyAndFormDetailsStartWithTheCrashReporterDisclaimer() { + CrashSummary summary = CrashSummary.extract(new RuntimeException("boom"), LOG_TEXT, terasology()); + + assertTrue(summary.buildBody(null).startsWith(CrashSummary.DISCLAIMER), summary.buildBody(null)); + assertTrue(summary.buildIssueFormFields(null).get("actual_behavior").startsWith(CrashSummary.DISCLAIMER)); + } + @Test void issueFormFieldsOmitVersionWhenNotFoundInsteadOfSayingUnknown() { CrashSummary summary = CrashSummary.extract(new RuntimeException("boom"), "no relevant lines here", terasology()); From 6607e6b8fb81ead97bcc54bfbcc4bee5648cb67f Mon Sep 17 00:00:00 2001 From: soloturn Date: Wed, 7 Oct 2026 20:08:13 +0200 Subject: [PATCH 3/4] fix(reporter): PasteBin upload aborts at an overall deadline and on cancel PR #73 review: socket timeouts only bound inactivity, so a trickling server could hold the request open and a retry could duplicate the paste. A watchdog now aborts the request at the overall deadline or when the uploading thread is interrupted. Tests bind the loopback server to 127.0.0.1 explicitly. Co-Authored-By: Claude Sonnet 5.5 --- .../pages/PastebinUploadRunnable.java | 57 ++++++++++++++++--- .../pages/PastebinUploadRunnableTest.java | 53 ++++++++++++++++- 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/cr-core/src/main/java/org/terasology/crashreporter/pages/PastebinUploadRunnable.java b/cr-core/src/main/java/org/terasology/crashreporter/pages/PastebinUploadRunnable.java index e2db7f7..bdd85f2 100644 --- a/cr-core/src/main/java/org/terasology/crashreporter/pages/PastebinUploadRunnable.java +++ b/cr-core/src/main/java/org/terasology/crashreporter/pages/PastebinUploadRunnable.java @@ -15,12 +15,18 @@ import org.apache.http.util.EntityUtils; import java.io.IOException; +import java.net.SocketTimeoutException; import java.net.URI; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; /** * Upload the content to PasteBin @@ -29,6 +35,7 @@ public class PastebinUploadRunnable implements Callable { static final String PASTEBIN_API_URL = "https://pastebin.com/api/api_post.php"; static final int TIMEOUT_MILLIS = 30_000; + private static final long WATCHDOG_POLL_MILLIS = 50; /** * Username Terasology @@ -65,16 +72,50 @@ public URL call() throws IOException { new BasicNameValuePair("api_paste_format", "apache"), // closest to a log file format new BasicNameValuePair("api_paste_expire_date", "1M")); - try (CloseableHttpClient client = HttpClientBuilder.create().setDefaultRequestConfig(config).build()) { - HttpPost post = new HttpPost(apiUri); - post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8)); - try (CloseableHttpResponse response = client.execute(post)) { - String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8).trim(); - if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK || !body.startsWith("http")) { - throw new IOException(body); // PasteBin reports errors as "Bad API request, ..." text + HttpPost post = new HttpPost(apiUri); + post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8)); + + // Socket timeouts only bound inactivity, so a trickling server could hold the request open; + // the watchdog aborts it at an overall deadline, or as soon as this thread is interrupted + // (UploadPanel cancels its Future on timeout) - blocking socket I/O ignores interrupts itself. + final Thread owner = Thread.currentThread(); + final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis); + final AtomicBoolean timedOut = new AtomicBoolean(); + final ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "PasteBin upload watchdog"); + thread.setDaemon(true); + return thread; + } + }); + watchdog.scheduleWithFixedDelay(new Runnable() { + @Override + public void run() { + boolean expired = System.nanoTime() - deadline >= 0; + if (expired || owner.isInterrupted()) { + timedOut.set(expired); + post.abort(); } - return new URL(body); } + }, WATCHDOG_POLL_MILLIS, WATCHDOG_POLL_MILLIS, TimeUnit.MILLISECONDS); + + try (CloseableHttpClient client = HttpClientBuilder.create().setDefaultRequestConfig(config).build(); + CloseableHttpResponse response = client.execute(post)) { + String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8).trim(); + if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK || !body.startsWith("http")) { + throw new IOException(body); // PasteBin reports errors as "Bad API request, ..." text + } + return new URL(body); + } catch (IOException e) { + if (timedOut.get() && !(e instanceof SocketTimeoutException)) { + SocketTimeoutException timeout = new SocketTimeoutException("PasteBin upload exceeded " + timeoutMillis + " ms"); + timeout.initCause(e); + throw timeout; + } + throw e; + } finally { + watchdog.shutdownNow(); } } } diff --git a/cr-core/src/test/java/org/terasology/crashreporter/pages/PastebinUploadRunnableTest.java b/cr-core/src/test/java/org/terasology/crashreporter/pages/PastebinUploadRunnableTest.java index 9cf9282..bf731b8 100644 --- a/cr-core/src/test/java/org/terasology/crashreporter/pages/PastebinUploadRunnableTest.java +++ b/cr-core/src/test/java/org/terasology/crashreporter/pages/PastebinUploadRunnableTest.java @@ -31,7 +31,8 @@ class PastebinUploadRunnableTest { @BeforeEach void startServer() throws IOException { - server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + // Explicit IPv4: uri() below is built from 127.0.0.1, so both must be the same address family. + server = HttpServer.create(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0), 0); } @AfterEach @@ -85,6 +86,56 @@ void hungServerTimesOutInsteadOfBlocking() { assertTrue((System.nanoTime() - start) / 1_000_000 < 5000, "timeout must actually stop the request"); } + @Test + void trickleOfBytesStillHitsTheOverallDeadline() { + server.createContext("/api/api_post.php", exchange -> { + exchange.sendResponseHeaders(200, 0); + try (OutputStream out = exchange.getResponseBody()) { + // One byte every 50ms never trips the 400ms socket timeout, only the overall deadline. + for (int i = 0; i < 200 && release.getCount() > 0; i++) { + out.write('x'); + out.flush(); + Thread.sleep(50); + } + } catch (IOException | InterruptedException ignored) { + // client aborted + } + }); + server.start(); + + long start = System.nanoTime(); + assertThrows(SocketTimeoutException.class, () -> new PastebinUploadRunnable("x", uri(), 400).call()); + assertTrue((System.nanoTime() - start) / 1_000_000 < 5000, "deadline must abort a trickling response"); + } + + @Test + void interruptAbortsTheRequest() throws Exception { + server.createContext("/api/api_post.php", exchange -> { + try { + release.await(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + }); + server.start(); + + AtomicReference failure = new AtomicReference<>(); + Thread uploader = new Thread(() -> { + try { + new PastebinUploadRunnable("x", uri(), 60_000).call(); + } catch (Throwable t) { + failure.set(t); + } + }); + uploader.start(); + Thread.sleep(300); // let the request reach the hung handler + uploader.interrupt(); + uploader.join(5000); + + assertTrue(!uploader.isAlive(), "interrupt must abort the in-flight request"); + assertTrue(failure.get() instanceof IOException, String.valueOf(failure.get())); + } + private static void reply(com.sun.net.httpserver.HttpExchange exchange, int code, String text) throws IOException { byte[] bytes = text.getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(code, bytes.length); From 158c1530ddbb5bc5fe1bd5477176ecba898aedf2 Mon Sep 17 00:00:00 2001 From: soloturn Date: Wed, 7 Oct 2026 20:34:32 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(reporter):=20PR=20#73=20review=20nits?= =?UTF-8?q?=20=E2=80=94=20one-shot=20upload=20deadline,=20DISCLAIMER=20dec?= =?UTF-8?q?laration=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overall deadline is now a single scheduled abort; only the interrupt check still polls, at 250 ms. DISCLAIMER moves above the private statics to satisfy DeclarationOrder. Co-Authored-By: Claude Sonnet 5.5 --- .../crashreporter/pages/CrashSummary.java | 6 +++--- .../pages/PastebinUploadRunnable.java | 16 ++++++++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/cr-core/src/main/java/org/terasology/crashreporter/pages/CrashSummary.java b/cr-core/src/main/java/org/terasology/crashreporter/pages/CrashSummary.java index 3b244db..08ee4ee 100644 --- a/cr-core/src/main/java/org/terasology/crashreporter/pages/CrashSummary.java +++ b/cr-core/src/main/java/org/terasology/crashreporter/pages/CrashSummary.java @@ -43,12 +43,12 @@ */ public final class CrashSummary { + /** First line of every generated issue text, so readers know where it came from. */ + static final String DISCLAIMER = "> _Generated by CrashReporter_"; + private static final int MAX_STACK_LINES = 15; private static final int MAX_MODULES_LISTED = 30; private static final int MAX_TITLE_MESSAGE_LENGTH = 80; - - /** First line of every generated issue text, so readers know where it came from. */ - static final String DISCLAIMER = "> _Generated by CrashReporter_"; private static final int MAX_EXCEPTIONS_LISTED = 10; private static final int CONTEXT_LINES_BEFORE = 5; private static final String NO_TAB_LABEL = "this crash"; diff --git a/cr-core/src/main/java/org/terasology/crashreporter/pages/PastebinUploadRunnable.java b/cr-core/src/main/java/org/terasology/crashreporter/pages/PastebinUploadRunnable.java index bdd85f2..ac1fad6 100644 --- a/cr-core/src/main/java/org/terasology/crashreporter/pages/PastebinUploadRunnable.java +++ b/cr-core/src/main/java/org/terasology/crashreporter/pages/PastebinUploadRunnable.java @@ -35,7 +35,7 @@ public class PastebinUploadRunnable implements Callable { static final String PASTEBIN_API_URL = "https://pastebin.com/api/api_post.php"; static final int TIMEOUT_MILLIS = 30_000; - private static final long WATCHDOG_POLL_MILLIS = 50; + private static final long INTERRUPT_POLL_MILLIS = 250; /** * Username Terasology @@ -79,7 +79,6 @@ public URL call() throws IOException { // the watchdog aborts it at an overall deadline, or as soon as this thread is interrupted // (UploadPanel cancels its Future on timeout) - blocking socket I/O ignores interrupts itself. final Thread owner = Thread.currentThread(); - final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis); final AtomicBoolean timedOut = new AtomicBoolean(); final ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { @Override @@ -89,16 +88,21 @@ public Thread newThread(Runnable r) { return thread; } }); + watchdog.schedule(new Runnable() { + @Override + public void run() { + timedOut.set(true); + post.abort(); + } + }, timeoutMillis, TimeUnit.MILLISECONDS); watchdog.scheduleWithFixedDelay(new Runnable() { @Override public void run() { - boolean expired = System.nanoTime() - deadline >= 0; - if (expired || owner.isInterrupted()) { - timedOut.set(expired); + if (owner.isInterrupted()) { post.abort(); } } - }, WATCHDOG_POLL_MILLIS, WATCHDOG_POLL_MILLIS, TimeUnit.MILLISECONDS); + }, INTERRUPT_POLL_MILLIS, INTERRUPT_POLL_MILLIS, TimeUnit.MILLISECONDS); try (CloseableHttpClient client = HttpClientBuilder.create().setDefaultRequestConfig(config).build(); CloseableHttpResponse response = client.execute(post)) {