Skip to content
14 changes: 14 additions & 0 deletions cr-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ dependencies {
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 <dependencies> 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")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@
import java.net.URISyntaxException;
import java.net.URL;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import java.util.function.Supplier;

/**
Expand All @@ -37,6 +45,8 @@ public class UploadPanel extends JPanel {

private static final long serialVersionUID = -8247883237201535146L;

private static final long DEFAULT_UPLOAD_TIMEOUT_SECONDS = 30;

private JButton uploadPasteBinButton;
private boolean isComplete;
private URL uploadURL;
Expand All @@ -47,14 +57,27 @@ public class UploadPanel extends JPanel {

private final Supplier<String> logFileNameSupplier;

private final long uploadTimeoutSeconds;

private JButton uploadSkipButton;

private JLabel titleLabel;

public UploadPanel(GlobalProperties properties, Supplier<String> logTextSupp, Supplier<String> logFileNameSupp) {
this(properties, logTextSupp, logFileNameSupp, DEFAULT_UPLOAD_TIMEOUT_SECONDS);
}

/**
* @param uploadTimeoutSeconds how long {@link #upload} waits for the upload {@link Callable} before treating it
* as failed - package-private constructor so tests can use a short timeout instead of
* {@link #DEFAULT_UPLOAD_TIMEOUT_SECONDS}.
*/
UploadPanel(GlobalProperties properties, Supplier<String> logTextSupp, Supplier<String> logFileNameSupp,
long uploadTimeoutSeconds) {

this.textSupplier = logTextSupp;
this.logFileNameSupplier = logFileNameSupp;
this.uploadTimeoutSeconds = uploadTimeoutSeconds;
setLayout(new BorderLayout(50, 20));
statusLabel = new JLabel(I18N.getMessage("noUpload"), SwingConstants.RIGHT);
statusLabel.setFont(statusLabel.getFont().deriveFont(Font.BOLD));
Expand Down Expand Up @@ -119,22 +142,69 @@ public URL getUploadedFileURL() {
return uploadURL;
}

/**
* 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".
*/
private void upload(final Callable<URL> callable) {
Runnable runnable = new Runnable() {
final ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r, "Upload");
thread.setDaemon(true);
return thread;
}
});
final Future<URL> future = executor.submit(callable);

Thread watcher = new Thread(new Runnable() {
@Override
public void run() {
try {
URL link = callable.call();
uploadSuccess(link);
} catch (Exception e) {
uploadFailed(e);
awaitUpload(future, uploadTimeoutSeconds, new Consumer<URL>() {
@Override
public void accept(URL link) {
uploadSuccess(link);
}
}, new Consumer<Exception>() {
@Override
public void accept(Exception e) {
uploadFailed(e);
}
});
} finally {
executor.shutdownNow();
}
}
};
}, "Upload-Watcher");
watcher.setDaemon(true);
watcher.start();
}

Thread thread = new Thread(runnable, "Upload");
thread.start();
/**
* Waits up to {@code timeoutSeconds} for {@code future}, then dispatches to exactly one of the
* two callbacks - split out from {@link #upload} as a plain, Swing-free method so the timeout
* and exception-unwrapping logic can be tested directly against a real {@link Future} without
* needing a full {@code UploadPanel}/button-click harness.
*/
static void awaitUpload(Future<URL> future, long timeoutSeconds, Consumer<URL> onSuccess, Consumer<Exception> onFailure) {
try {
URL link = future.get(timeoutSeconds, TimeUnit.SECONDS);
onSuccess.accept(link);
} catch (TimeoutException e) {
future.cancel(true);
onFailure.accept(new IOException(
"Upload timed out after " + timeoutSeconds + "s - the server may be unreachable", e));
} catch (ExecutionException e) {
Throwable cause = e.getCause();
onFailure.accept(cause instanceof Exception ? (Exception) cause : e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
onFailure.accept(e);
}
}

private void updateStatus() {
Expand Down Expand Up @@ -168,7 +238,16 @@ public void run() {
});
}

private void uploadFailed(final Exception e) {
// Package-private so tests can call it directly, no test-only seam needed.
void uploadFailed(final Exception e) {
// Printed unconditionally, not just shown in the dialog below: a JOptionPane only reaches
// whoever is watching the screen at that exact moment, and leaves no trace at all once
// it's dismissed - nothing else in this codebase logs upload failures anywhere. Whoever
// launched this process (a script, a supervisor, a developer tailing output) needs to be
// able to find out what happened after the fact, not just the person who happened to be
// looking right then.
e.printStackTrace(System.err);

SwingUtilities.invokeLater(new Runnable() {

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright 2026 The Terasology Foundation
// SPDX-License-Identifier: Apache-2.0

package org.terasology.crashreporter.pages;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.terasology.crashreporter.GlobalProperties;

import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;

import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Regression test for an upload failure that previously left no trace anywhere once its
* {@code JOptionPane} was dismissed - found while manually testing the reporter dialog: a real
* exception (a missing-Jackson {@code NoClassDefFoundError} - see the jackson-bom fix elsewhere in
* this commit) only ever showed up in a popup, with nothing printed to stderr for whoever launched
* the process to find afterward.
*/
class UploadPanelFailureLoggingTest {

private PrintStream originalErr;
private ByteArrayOutputStream capturedErr;

@BeforeEach
void redirectStderr() {
originalErr = System.err;
capturedErr = new ByteArrayOutputStream();
System.setErr(new PrintStream(capturedErr, true, StandardCharsets.UTF_8));
}

@AfterEach
void restoreStderr() {
System.setErr(originalErr);
}

@Test
void aFailedUploadIsPrintedToStderrNotJustShownInAPopup() {
UploadPanel panel = new UploadPanel(new GlobalProperties(), () -> "log text", () -> "log.txt");

// Synchronous stderr print, no thread/timing dance needed.
panel.uploadFailed(new IllegalStateException("upload failed: missing Jackson class"));

String stderr = capturedErr.toString(StandardCharsets.UTF_8);
assertTrue(stderr.contains("IllegalStateException"), "Expected the exception type on stderr, got: " + stderr);
assertTrue(stderr.contains("upload failed: missing Jackson class"),
"Expected the exception message on stderr, got: " + stderr);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright 2026 The Terasology Foundation
// SPDX-License-Identifier: Apache-2.0

package org.terasology.crashreporter.pages;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

/**
* Regression tests for the PasteBin upload hang found while manually testing the reporter dialog:
* {@code PastebinUploadRunnable} makes a real HTTP call with no timeout of its own, so a slow or
* unreachable server left the upload button disabled and the status label reading "please wait"
* forever, with no way to tell "still working" from "never finishing".
*
* <p>Fully offline: {@code PastebinUploadRunnable} is never instantiated here, so no test makes a
* real HTTP call. "Slow"/"failing" uploads are hand-written {@link Callable}s (sleep-then-return,
* throw); the {@code pastebin.com} URLs below are only ever passed to {@link URL#URL(String)},
* which parses a string and never opens a connection.
*/
class UploadPanelTest {

private ExecutorService executor;

@AfterEach
void shutdown() {
if (executor != null) {
executor.shutdownNow();
}
}

@Test
void aSlowUploadFailsWithATimeoutInsteadOfHangingForever() throws Exception {
executor = Executors.newSingleThreadExecutor();
Future<URL> future = executor.submit(new Callable<URL>() {
@Override
public URL call() throws InterruptedException, MalformedURLException {
// Longer than the 1-second timeout below - simulates the observed hang.
Thread.sleep(5_000);
return new URL("https://pastebin.com/never-reached");
}
});

AtomicReference<URL> successResult = new AtomicReference<>();
AtomicReference<Exception> failureResult = new AtomicReference<>();
CountDownLatch done = new CountDownLatch(1);

UploadPanel.awaitUpload(future, 1, link -> {
successResult.set(link);
done.countDown();
}, e -> {
failureResult.set(e);
done.countDown();
});

assertTrue(done.await(1, TimeUnit.SECONDS), "awaitUpload must return once its own timeout elapses");
assertNull(successResult.get(), "a timed-out upload must not report success");
assertTrue(failureResult.get() instanceof IOException, "expected a timeout to surface as an IOException, got: " + failureResult.get());
assertTrue(failureResult.get().getMessage().contains("timed out"),
"expected a message naming the timeout, got: " + failureResult.get().getMessage());
assertTrue(future.isCancelled(), "the underlying upload task should be cancelled once it's timed out");
}

@Test
void aFastUploadReportsSuccess() throws Exception {
executor = Executors.newSingleThreadExecutor();
final URL expected = new URL("https://pastebin.com/abc123");
Future<URL> future = executor.submit(new Callable<URL>() {
@Override
public URL call() {
return expected;
}
});

AtomicReference<URL> successResult = new AtomicReference<>();
UploadPanel.awaitUpload(future, 5, successResult::set, e -> fail("expected success, got: " + e));

assertEquals(expected, successResult.get());
}

@Test
void anUnderlyingFailureIsUnwrappedFromExecutionException() throws Exception {
executor = Executors.newSingleThreadExecutor();
final IOException realCause = new IOException("invalid API key");
Future<URL> future = executor.submit(new Callable<URL>() {
@Override
public URL call() throws IOException {
throw realCause;
}
});

AtomicReference<Exception> failureResult = new AtomicReference<>();
UploadPanel.awaitUpload(future, 5, link -> fail("expected failure"), failureResult::set);

assertEquals(realCause, failureResult.get(),
"expected the real cause unwrapped from ExecutionException, not the wrapper itself");
}
}