Skip to content
Open
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
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,74 @@ public URL getUploadedFileURL() {
return uploadURL;
}

/** Package-private test hook - equivalent to a real "PasteBin" click, but with a caller-supplied Callable. */
void uploadForTesting(Callable<URL> callable) {
upload(callable);
}

/**
* 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 @@ -169,6 +244,14 @@ public void run() {
}

private 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,65 @@
// 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.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Callable;

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() throws InterruptedException {
UploadPanel panel = new UploadPanel(new GlobalProperties(), () -> "log text", () -> "log.txt");

final Exception cause = new IllegalStateException("upload failed: missing Jackson class");
panel.uploadForTesting(new Callable<URL>() {
@Override
public URL call() throws Exception {
throw cause;
}
});

long deadline = System.currentTimeMillis() + 2000;
while (capturedErr.size() == 0 && System.currentTimeMillis() < deadline) {
Thread.sleep(20);
}

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");
}
}