Compare commits

...
4 changed files with 316 additions and 0 deletions
@@ -25,6 +25,7 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobExecutorService;
import stirling.software.common.util.ExceptionUtils;
@Aspect
@Component
@@ -88,6 +89,14 @@ public class AutoJobAspect {
"AutoJobAspect caught exception during job execution: {}",
ex.getMessage(),
ex);
// A native library that will not load is an Error, so it
// reaches here untranslated from every tool that uses one.
if (ExceptionUtils.isNativeLibraryFailure(ex)) {
throw new RuntimeException(
ExceptionUtils
.createNativeLibraryUnavailableException(
ex));
}
// Rethrow RuntimeException as-is to preserve exception type
if (ex instanceof RuntimeException) {
throw (RuntimeException) ex;
@@ -172,6 +181,15 @@ public class AutoJobAspect {
ex.getMessage(),
ex);
// A missing native library is a permanent condition; retrying
// only burns the remaining attempts.
if (ExceptionUtils.isNativeLibraryFailure(ex)) {
throw new RuntimeException(
ExceptionUtils
.createNativeLibraryUnavailableException(
ex));
}
// Check if we should retry
if (currentAttempt < maxRetries) {
log.info(
@@ -581,6 +581,64 @@ public class ExceptionUtils {
return new IOException(message, cause);
}
/** Package prefix of the JPDFium binding whose natives can fail to load. */
private static final String NATIVE_LIBRARY_PACKAGE = "stirling.software.jpdfium.";
/** Depth cap for cause-chain walks, so a cyclic chain cannot spin forever. */
private static final int MAX_CAUSE_DEPTH = 16;
/**
* Create the exception for a native library that could not be loaded.
*
* @param cause the linkage error raised by the native loader
* @return a typed application exception carrying a user-facing message
*/
public static NativeLibraryUnavailableException createNativeLibraryUnavailableException(
Throwable cause) {
requireNonNull(cause, "cause");
String message =
getMessage(
ErrorCode.NATIVE_LIBRARY_UNAVAILABLE.getMessageKey(),
ErrorCode.NATIVE_LIBRARY_UNAVAILABLE.getDefaultMessage());
return new NativeLibraryUnavailableException(
message, cause, ErrorCode.NATIVE_LIBRARY_UNAVAILABLE.getCode());
}
/**
* True when a throwable is a native library failing to load rather than a genuine processing
* error.
*
* <p>Native loaders raise a LinkageError - ExceptionInInitializerError the first time the class
* is touched, NoClassDefFoundError every time after. Both are Errors, so they pass straight
* through catch (Exception) and arrive as an opaque failure with a null message. The check is
* deliberately narrow: an unrelated LinkageError, say a dependency mismatch, must not be
* reported to the user as a missing native library.
*/
public static boolean isNativeLibraryFailure(Throwable throwable) {
if (!(throwable instanceof LinkageError)) {
return false;
}
// Bounded walk: cause chains can be cyclic, and a real one is never this deep.
Throwable current = throwable;
for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) {
if (current.getClass().getName().startsWith(NATIVE_LIBRARY_PACKAGE)) {
return true;
}
String message = current.getMessage();
if (message != null && message.contains(NATIVE_LIBRARY_PACKAGE)) {
return true;
}
for (StackTraceElement frame : current.getStackTrace()) {
if (frame.getClassName().startsWith(NATIVE_LIBRARY_PACKAGE)) {
return true;
}
}
Throwable next = current.getCause();
current = next == current ? null : next;
}
return false;
}
public static IOException createImageReadException(String filename) {
requireNonNull(filename, "filename");
String message =
@@ -1218,6 +1276,10 @@ public class ExceptionUtils {
// System errors
MD5_ALGORITHM("E080", "error.md5Algorithm", "MD5 algorithm not available"),
NATIVE_LIBRARY_UNAVAILABLE(
"E082",
"error.nativeLibraryUnavailable",
"A native library required for this operation could not be loaded on this system. This usually means the container image or platform is unsupported. Check the server logs for the underlying loader error."),
OUT_OF_MEMORY_DPI(
"E081",
"error.outOfMemoryDpi",
@@ -1277,6 +1339,13 @@ public class ExceptionUtils {
}
}
/** Exception thrown when a required native library could not be loaded on this host. */
public static class NativeLibraryUnavailableException extends BaseAppException {
public NativeLibraryUnavailableException(String message, Throwable cause, String code) {
super(message, cause, code);
}
}
/** Exception thrown when FFmpeg is not available on the host system. */
public static class FfmpegRequiredException extends BaseAppException {
public FfmpegRequiredException(String message, String errorCode) {
@@ -0,0 +1,104 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import stirling.software.common.util.ExceptionUtils.BaseAppException;
import stirling.software.common.util.ExceptionUtils.NativeLibraryUnavailableException;
import stirling.software.jpdfium.exception.NativeLoadException;
/**
* A native library that will not load surfaces as a LinkageError, which slips past every
* catch(Exception) guard and reaches the user as a 500 with a null message. Detection has to be
* narrow enough that an unrelated LinkageError is never reported as a missing native library.
*/
@DisplayName("Native library failure detection")
class ExceptionUtilsNativeLibraryTest {
private static Throwable withJpdfiumFrame(Throwable t) {
t.setStackTrace(
new StackTraceElement[] {
new StackTraceElement(
"stirling.software.jpdfium.panama.NativeLoader",
"ensureLoaded",
"NativeLoader.java",
44)
});
return t;
}
@Test
@DisplayName("First touch of the class: ExceptionInInitializerError wrapping the loader error")
void detectsFirstTouchFailure() {
Throwable cause = new NativeLoadException("Failed to load native library");
ExceptionInInitializerError error = new ExceptionInInitializerError(cause);
assertThat(ExceptionUtils.isNativeLibraryFailure(error)).isTrue();
}
@Test
@DisplayName("Every touch after: NoClassDefFoundError naming the uninitialised class")
void detectsSubsequentFailure() {
NoClassDefFoundError error =
new NoClassDefFoundError(
"Could not initialize class stirling.software.jpdfium.panama.JpdfiumLib");
assertThat(ExceptionUtils.isNativeLibraryFailure(error)).isTrue();
}
@Test
@DisplayName("Detected from a stack frame when the message gives nothing away")
void detectsFromStackFrame() {
ExceptionInInitializerError error = new ExceptionInInitializerError();
withJpdfiumFrame(error);
assertThat(ExceptionUtils.isNativeLibraryFailure(error)).isTrue();
}
@Test
@DisplayName("An unrelated LinkageError is not reported as a missing native library")
void ignoresUnrelatedLinkageError() {
NoSuchMethodError error =
new NoSuchMethodError("org.example.Widget.spin()Ljava/lang/String;");
assertThat(ExceptionUtils.isNativeLibraryFailure(error)).isFalse();
}
@Test
@DisplayName("Ordinary exceptions are never native library failures")
void ignoresOrdinaryExceptions() {
assertThat(ExceptionUtils.isNativeLibraryFailure(new RuntimeException("boom"))).isFalse();
assertThat(ExceptionUtils.isNativeLibraryFailure(new java.io.IOException("boom")))
.isFalse();
}
@Test
@DisplayName("A cyclic cause chain terminates instead of spinning")
void survivesCyclicCauseChain() {
// Java rejects direct self-causation but not a cycle built across two throwables.
NoClassDefFoundError inner = new NoClassDefFoundError("nothing to see here");
NoClassDefFoundError outer = new NoClassDefFoundError("still nothing");
outer.initCause(inner);
inner.initCause(outer);
assertThat(ExceptionUtils.isNativeLibraryFailure(outer)).isFalse();
}
@Test
@DisplayName("Translates to a typed application exception the error handler can render")
void translatesToTypedException() {
ExceptionInInitializerError error =
new ExceptionInInitializerError(
new NativeLoadException("Failed to load native library"));
NativeLibraryUnavailableException translated =
ExceptionUtils.createNativeLibraryUnavailableException(error);
assertThat(translated).isInstanceOf(BaseAppException.class);
assertThat(translated.getErrorCode()).isEqualTo("E082");
assertThat(translated.getMessage()).isNotBlank();
assertThat(translated.getCause()).isSameAs(error);
}
}
@@ -0,0 +1,125 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.general.MergePdfsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.jpdfium.exception.NativeLoadException;
/**
* Merging depends on JPDFium's native library. When those natives cannot be loaded the JVM raises
* an ExceptionInInitializerError, which is an Error rather than an Exception.
*
* <p>Translation happens once in AutoJobAspect rather than at each of the dozen call sites that
* touch JPDFium, so what this class pins down is the contract the controller has to keep for that
* to work: the failure must travel out intact. In particular the per-file validation loop must not
* absorb it and go on to report every input as a corrupt PDF.
*/
class MergeControllerNativeLoadTest {
private MergeController mergeController;
private TempFileManager tempFileManager;
private byte[] pdfBytes;
@BeforeEach
void setUp() throws Exception {
CustomPDFDocumentFactory factory = org.mockito.Mockito.mock(CustomPDFDocumentFactory.class);
tempFileManager = org.mockito.Mockito.mock(TempFileManager.class);
when(tempFileManager.createTempFile(any()))
.thenAnswer(inv -> Files.createTempFile("merge-native", ".pdf").toFile());
when(tempFileManager.convertMultipartFileToFile(any(MultipartFile.class)))
.thenAnswer(
inv -> {
MultipartFile mf = inv.getArgument(0);
Path p = Files.createTempFile("merge-native-in", ".pdf");
Files.write(p, mf.getBytes());
return p.toFile();
});
mergeController = new MergeController(factory, tempFileManager);
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
pdfBytes = baos.toByteArray();
}
}
@Test
@DisplayName("Lets a native load failure travel out so the aspect can translate it")
void propagatesNativeLoadFailure() throws Exception {
MergePdfsRequest request = new MergePdfsRequest();
request.setFileInput(
new MultipartFile[] {
new MockMultipartFile(
"fileInput", "a.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes),
new MockMultipartFile(
"fileInput", "b.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes)
});
try (MockedStatic<PdfDocument> natives = mockStatic(PdfDocument.class)) {
natives.when(() -> PdfDocument.open(any(Path.class)))
.thenThrow(
new ExceptionInInitializerError(
new NativeLoadException("Failed to load native library")));
Throwable thrown =
assertThrows(Throwable.class, () -> mergeController.mergePdfs(request, null));
// Not swallowed by the per-file catch(Exception) and turned into "corrupt PDF".
assertInstanceOf(
LinkageError.class,
thrown,
"the native failure must reach the aspect, not be reported as a bad file");
assertTrue(
ExceptionUtils.isNativeLibraryFailure(thrown),
"the aspect must be able to recognise what came out of the controller");
}
}
@Test
@DisplayName("Cleans up the temp output when the natives cannot be loaded")
void doesNotLeakTempFilesWhenNativesUnavailable() throws Exception {
MergePdfsRequest request = new MergePdfsRequest();
request.setFileInput(
new MultipartFile[] {
new MockMultipartFile(
"fileInput", "a.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes)
});
try (MockedStatic<PdfDocument> natives = mockStatic(PdfDocument.class)) {
natives.when(() -> PdfDocument.open(any(Path.class)))
.thenThrow(
new ExceptionInInitializerError(
new NativeLoadException("Failed to load native library")));
assertThrows(Throwable.class, () -> mergeController.mergePdfs(request, null));
}
org.mockito.Mockito.verify(tempFileManager, org.mockito.Mockito.atLeastOnce())
.deleteTempFile(any(File.class));
}
}